diff --git a/app/app.iml b/app/app.iml new file mode 100644 index 0000000..6e42112 --- /dev/null +++ b/app/app.iml @@ -0,0 +1,133 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..c700c4e --- /dev/null +++ b/app/src/main/AndroidManifest.xml @@ -0,0 +1,69 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/java/net/moasdawiki/app/AndroidSettings.java b/app/src/main/java/net/moasdawiki/app/AndroidSettings.java new file mode 100644 index 0000000..f607e03 --- /dev/null +++ b/app/src/main/java/net/moasdawiki/app/AndroidSettings.java @@ -0,0 +1,37 @@ +/* + * MoasdaWiki App + * Copyright (C) 2008 - 2020 Herbert Reiter (herbert@moasdawiki.net) + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package net.moasdawiki.app; + +import net.moasdawiki.base.Logger; +import net.moasdawiki.base.Settings; +import net.moasdawiki.service.repository.RepositoryService; + +import org.jetbrains.annotations.NotNull; + +public class AndroidSettings extends Settings { + + public AndroidSettings(@NotNull Logger logger, @NotNull RepositoryService repositoryService, @NotNull String configFileName) { + super(logger, repositoryService, configFileName); + } + + @NotNull + public String getVersion() { + return BuildConfig.VERSION_NAME; + } +} diff --git a/app/src/main/java/net/moasdawiki/app/CalendarAccountAuthenticator.java b/app/src/main/java/net/moasdawiki/app/CalendarAccountAuthenticator.java new file mode 100644 index 0000000..c4af815 --- /dev/null +++ b/app/src/main/java/net/moasdawiki/app/CalendarAccountAuthenticator.java @@ -0,0 +1,83 @@ +/* + * MoasdaWiki App + * Copyright (C) 2008 - 2020 Herbert Reiter (herbert@moasdawiki.net) + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package net.moasdawiki.app; + +import android.accounts.AbstractAccountAuthenticator; +import android.accounts.Account; +import android.accounts.AccountAuthenticatorResponse; +import android.content.Context; +import android.os.Bundle; + +import org.jetbrains.annotations.Nullable; + +/** + * Stub authenticator, required for calendar sync adapter. + * + * @see "https://developer.android.com/training/sync-adapters/creating-authenticator#CreateAuthenticator" + */ +public class CalendarAccountAuthenticator extends AbstractAccountAuthenticator { + + public CalendarAccountAuthenticator(Context context) { + super(context); + } + + @Override + public Bundle editProperties(AccountAuthenticatorResponse response, String accountType) { + // Editing properties is not supported + throw new UnsupportedOperationException(); + } + + @Nullable + @Override + public Bundle addAccount(AccountAuthenticatorResponse response, String accountType, String authTokenType, String[] requiredFeatures, Bundle options) { + // Don't add additional accounts + return null; + } + + @Nullable + @Override + public Bundle confirmCredentials(AccountAuthenticatorResponse response, Account account, Bundle options) { + // Ignore attempts to confirm credentials + return null; + } + + @Override + public Bundle getAuthToken(AccountAuthenticatorResponse response, Account account, String authTokenType, Bundle options) { + // Getting an authentication token is not supported + throw new UnsupportedOperationException(); + } + + @Override + public String getAuthTokenLabel(String authTokenType) { + // Getting a label for the auth token is not supported + throw new UnsupportedOperationException(); + } + + @Override + public Bundle updateCredentials(AccountAuthenticatorResponse response, Account account, String authTokenType, Bundle options) { + // Updating user credentials is not supported + throw new UnsupportedOperationException(); + } + + @Override + public Bundle hasFeatures(AccountAuthenticatorResponse response, Account account, String[] features) { + // Checking features for the account is not supported + throw new UnsupportedOperationException(); + } +} diff --git a/app/src/main/java/net/moasdawiki/app/CalendarAccountAuthenticatorService.java b/app/src/main/java/net/moasdawiki/app/CalendarAccountAuthenticatorService.java new file mode 100644 index 0000000..e503294 --- /dev/null +++ b/app/src/main/java/net/moasdawiki/app/CalendarAccountAuthenticatorService.java @@ -0,0 +1,45 @@ +/* + * MoasdaWiki App + * Copyright (C) 2008 - 2020 Herbert Reiter (herbert@moasdawiki.net) + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package net.moasdawiki.app; + +import android.app.Service; +import android.content.Intent; +import android.os.IBinder; + +import org.jetbrains.annotations.Nullable; + +/** + * Service to bind the CalendarAccountAuthenticator. + */ +public class CalendarAccountAuthenticatorService extends Service { + + private CalendarAccountAuthenticator authenticator; + + @Override + public void onCreate() { + // Create a new authenticator object + authenticator = new CalendarAccountAuthenticator(this); + } + + @Nullable + @Override + public IBinder onBind(Intent intent) { + return authenticator.getIBinder(); + } +} diff --git a/app/src/main/java/net/moasdawiki/app/CalendarContentProvider.java b/app/src/main/java/net/moasdawiki/app/CalendarContentProvider.java new file mode 100644 index 0000000..5c3ea9b --- /dev/null +++ b/app/src/main/java/net/moasdawiki/app/CalendarContentProvider.java @@ -0,0 +1,70 @@ +/* + * MoasdaWiki App + * Copyright (C) 2008 - 2020 Herbert Reiter (herbert@moasdawiki.net) + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package net.moasdawiki.app; + +import android.content.ContentProvider; +import android.content.ContentValues; +import android.database.Cursor; +import android.net.Uri; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +/** + * Stub content provider, necessary for sync adapter. + */ +public class CalendarContentProvider extends ContentProvider { + + @Override + public boolean onCreate() { + return true; + } + + @Nullable + @Override + public String getType(@NotNull Uri uri) { + // Return no type for MIME type + return null; + } + + @Nullable + @Override + public Cursor query(@NotNull Uri uri, @Nullable String[] projection, @Nullable String selection, @Nullable String[] selectionArgs, @Nullable String sortOrder) { + // query() always returns no results + return null; + } + + @Nullable + @Override + public Uri insert(@NotNull Uri uri, @Nullable ContentValues contentValues) { + // Provider doesn't support changes from outside + return null; + } + + @Override + public int delete(@NotNull Uri uri, @Nullable String s, @Nullable String[] strings) { + // Provider doesn't support changes from outside + return 0; + } + + @Override + public int update(@NotNull Uri uri, @Nullable ContentValues contentValues, @Nullable String s, @Nullable String[] strings) { + // Provider doesn't support changes from outside + return 0; + } +} diff --git a/app/src/main/java/net/moasdawiki/app/CalendarSyncAdapter.java b/app/src/main/java/net/moasdawiki/app/CalendarSyncAdapter.java new file mode 100644 index 0000000..de8435f --- /dev/null +++ b/app/src/main/java/net/moasdawiki/app/CalendarSyncAdapter.java @@ -0,0 +1,323 @@ +/* + * MoasdaWiki App + * Copyright (C) 2008 - 2020 Herbert Reiter (herbert@moasdawiki.net) + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package net.moasdawiki.app; + +import android.Manifest; +import android.accounts.Account; +import android.accounts.AccountManager; +import android.app.Activity; +import android.content.AbstractThreadedSyncAdapter; +import android.content.ContentProviderClient; +import android.content.ContentResolver; +import android.content.ContentUris; +import android.content.ContentValues; +import android.content.Context; +import android.content.SharedPreferences; +import android.content.SyncResult; +import android.content.pm.PackageManager; +import android.database.Cursor; +import android.net.Uri; +import android.os.Bundle; +import android.os.Handler; +import android.os.Looper; +import android.provider.CalendarContract; +import androidx.core.app.ActivityCompat; +import androidx.preference.PreferenceManager; + +import android.util.Log; +import android.widget.Toast; + +import net.moasdawiki.plugin.Plugin; +import net.moasdawiki.plugin.PluginService; +import net.moasdawiki.plugin.TerminPlugin; +import net.moasdawiki.util.PathUtils; + +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.util.Calendar; +import java.util.Collections; +import java.util.List; +import java.util.TimeZone; + +import static android.content.Context.ACCOUNT_SERVICE; + +/** + * Creates an Android calendar with all events found on Wiki pages and keeps them up-to-date. + */ +public class CalendarSyncAdapter extends AbstractThreadedSyncAdapter { + + private static final String TAG = "CalendarSyncAdapter"; + + static final String ACCOUNT_NAME = "MoasdaWiki"; + static final String ACCOUNT_TYPE = "net.moasdawiki"; + static final String PROVIDER_NAME = "net.moasdawiki.app.provider"; + + private static final String CALENDAR_NAME = "MoasdaWiki Events"; + private static final Uri CALENDAR_URI = CalendarContract.Calendars.CONTENT_URI; + private static final Uri EVENT_URI = CalendarContract.Events. CONTENT_URI; + private static final Uri REMINDER_URI = CalendarContract.Reminders.CONTENT_URI; + + private final ContentResolver contentResolver; + + @SuppressWarnings("SameParameterValue") + CalendarSyncAdapter(Context context, boolean autoInitialize, boolean allowParallelSyncs) { + super(context, autoInitialize, allowParallelSyncs); + contentResolver = context.getContentResolver(); + } + + @Override + public void onPerformSync(Account account, Bundle extras, String authority, ContentProviderClient provider, SyncResult syncResult) { + Log.d(TAG, "Begin of onPerformSync()"); + List events = getWikiEvents(); + String calendarId = createCalendar(); + if (calendarId != null) { + deleteAllEvents(calendarId); + addEvents(calendarId, events); + } + + Handler handler = new Handler(Looper.getMainLooper()); + handler.post(() -> { + Toast toast = Toast.makeText(getContext(), R.string.calendar_sync_finished, Toast.LENGTH_SHORT); + toast.show(); + }); + Log.d(TAG, "End of onPerformSync()"); + } + + /** + * Imports all events on Wiki pages to the Android calendar. + */ + @NotNull + private List getWikiEvents() { + Log.d(TAG, "Reading Wiki events"); + WikiEngineApplication app = (WikiEngineApplication) getContext(); + PluginService pluginService = app.getServiceLocator().getPluginService(); + TerminPlugin terminPlugin = null; + for (Plugin plugin : pluginService.getPlugins()) { + if (plugin instanceof TerminPlugin) { + terminPlugin = (TerminPlugin) plugin; + } + } + if (terminPlugin == null) { + Log.e(TAG, "TerminPlugin not found, cannot retrieve event list"); + return Collections.emptyList(); + } + List events = terminPlugin.getEvents(); + Log.d(TAG, "Wiki events found: " + events.size()); + return events; + } + + @NotNull + private Uri buildUri(@NotNull Uri uri) { + return uri.buildUpon() + .appendQueryParameter(android.provider.CalendarContract.CALLER_IS_SYNCADAPTER, "true") + .appendQueryParameter(CalendarContract.Calendars.ACCOUNT_NAME, ACCOUNT_NAME) + .appendQueryParameter(CalendarContract.Calendars.ACCOUNT_TYPE, ACCOUNT_TYPE).build(); + } + + /** + * Returns the ID of the event calendar. If the calendar doesn't exist, it is created. + * + * @return Calendar ID + */ + @Nullable + private String createCalendar() { + // Calendar already existing? + Log.d(TAG, "Check if calendar already exists"); + Uri calendarUri = buildUri(CALENDAR_URI); + try (Cursor cursor = contentResolver.query(calendarUri, + new String[] { CalendarContract.Calendars._ID }, + "(" + CalendarContract.Calendars.ACCOUNT_TYPE + " = ? AND " + CalendarContract.Calendars.NAME + " = ?)", + new String[] { ACCOUNT_TYPE, CALENDAR_NAME }, + null)) { + if (cursor != null && cursor.moveToFirst()) { + // Calendar is already existing + String calendarId = cursor.getString(0); + Log.d(TAG, "Calendar already exists, id=" + calendarId); + return calendarId; + } + } + + // Create a new calendar + Log.d(TAG, "Calendar does not exist, create new one"); + ContentValues cv = new ContentValues(); + cv.put(CalendarContract.Calendars.ACCOUNT_NAME, ACCOUNT_NAME); + cv.put(CalendarContract.Calendars.ACCOUNT_TYPE, ACCOUNT_TYPE); + cv.put(CalendarContract.Calendars.NAME, CALENDAR_NAME); + String displayName = getContext().getString(R.string.calendar_display_name); + cv.put(CalendarContract.Calendars.CALENDAR_DISPLAY_NAME, displayName); + //cv.put(CalendarContract.Calendars.CALENDAR_COLOR, 0xEA8561); + cv.put(CalendarContract.Calendars.CALENDAR_ACCESS_LEVEL, CalendarContract.Calendars.CAL_ACCESS_READ); + cv.put(CalendarContract.Calendars.OWNER_ACCOUNT, ACCOUNT_NAME); + cv.put(CalendarContract.Calendars.VISIBLE, 1); + cv.put(CalendarContract.Calendars.SYNC_EVENTS, 1); + Uri newCalendar = contentResolver.insert(calendarUri, cv); + Log.i(TAG, "Created calendar: " + newCalendar); + if (newCalendar != null) { + return newCalendar.getLastPathSegment(); + } else { + return null; + } + } + + /** + * Clears the calendar. This is necessary if e.g. a birthday has been changed or removed. + */ + private void deleteAllEvents(@NotNull String calendarId) { + Log.d(TAG, "Delete all events from calendar"); + Uri eventUri = buildUri(EVENT_URI); + try (Cursor cursor = contentResolver.query(eventUri, + new String[] { CalendarContract.Events._ID }, + "(" + CalendarContract.Events.CALENDAR_ID + " = ?)", + new String[] { calendarId }, + null)) { + int count = 0; + while (cursor != null && cursor.moveToNext()) { + Uri deleteUri = ContentUris.withAppendedId(eventUri, cursor.getInt(0)); + contentResolver.delete(deleteUri, null, null); + count++; + } + Log.d(TAG, "Deleted " + count + " events from calendar"); + } + } + + /** + * Adds all events from the event list. The first occurrence is in the current year, the events + * are repeated every year. + */ + private void addEvents(@NotNull String calendarId, @NotNull List events) { + Log.d(TAG, "Create calendar events"); + for (TerminPlugin.Event event : events) { + int day = event.dateFields.day != null ? event.dateFields.day : 1; + int month = event.dateFields.month != null ? event.dateFields.month : 1; // 1=January + String title = event.description; + if (title == null) { + title = PathUtils.extractWebName(event.pagePath); + } + String description = getContext().getString(R.string.calendar_date) + ": " + TerminPlugin.formatGermanDate(event.dateFields); + String eventId = addEvent(calendarId, day, month, title, description); + if (eventId != null) { + addReminder(eventId); + } + } + Log.i(TAG, "Created events: " + events.size()); + } + + /** + * Adds a single event to the calendar. + */ + @Nullable + private String addEvent(@NotNull String calendarId, int day, int month, @NotNull String title, @NotNull String description) { + Log.d(TAG, "Create calendar event: day=" + day + ", month=" + month + + ", title=" + title + ", description=" + description); + ContentValues cv = new ContentValues(); + cv.put(CalendarContract.Events.CALENDAR_ID, calendarId); + cv.put(CalendarContract.Events.TITLE, title); + cv.put(CalendarContract.Events.DESCRIPTION, description); + + TimeZone utc = TimeZone.getTimeZone("UTC"); + Calendar beginTime = Calendar.getInstance(utc); + int currentYear = beginTime.get(Calendar.YEAR); + beginTime.clear(); + beginTime.set(currentYear, month - 1, day); + cv.put(CalendarContract.Events.DTSTART, beginTime.getTimeInMillis()); + cv.put(CalendarContract.Events.DURATION, "PT1D"); + cv.put(CalendarContract.Events.ALL_DAY, 1); + cv.put(CalendarContract.Events.RRULE, "FREQ=YEARLY"); + cv.put(CalendarContract.Events.AVAILABILITY, CalendarContract.Events.AVAILABILITY_FREE); + + Uri eventUri = buildUri(EVENT_URI); + Uri newEvent = contentResolver.insert(eventUri, cv); + Log.i(TAG, "Created event: " + newEvent); + if (newEvent != null) { + return newEvent.getLastPathSegment(); + } else { + return null; + } + } + + /** + * Add a reminder to the given event. + */ + private void addReminder(@NotNull String eventId) { + Log.d(TAG, "Add reminder to eventId=" + eventId); + ContentValues cv = new ContentValues(); + cv.put(CalendarContract.Reminders.EVENT_ID, eventId); + cv.put(CalendarContract.Reminders.METHOD, CalendarContract.Reminders.METHOD_ALERT); + cv.put(CalendarContract.Reminders.MINUTES, 0); // 0 minutes offset + Uri reminderUri = buildUri(REMINDER_URI); + Uri newReminder = contentResolver.insert(reminderUri, cv); + Log.d(TAG, "Created reminder: " + newReminder); + } + + /** + * Initiates the calendar sync. + */ + public static void requestCalendarSync(@NotNull Activity activity) { + Log.d(TAG, "Requesting calendar synchronization"); + Context context = activity.getApplicationContext(); + + SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(context); + boolean calendarEnabled = preferences.getBoolean(Constants.PREFERENCES_CALENDAR_ENABLED, false); + if (!calendarEnabled) { + Log.d(TAG, "Calendar integration disabled, cancel operation"); + return; + } + + try { + if (ActivityCompat.checkSelfPermission(context, Manifest.permission.WRITE_CALENDAR) != PackageManager.PERMISSION_GRANTED) { + if (!ActivityCompat.shouldShowRequestPermissionRationale(activity, Manifest.permission.WRITE_CALENDAR)) { + Log.d(TAG, "User has permanently denied permission WRITE_CALENDAR, informing him"); + String hint = context.getString(R.string.calendar_permission_request, "WRITE_CALENDAR"); + Toast toast = Toast.makeText(context, hint, Toast.LENGTH_SHORT); + toast.show(); + } + Log.d(TAG, "Ask for permission WRITE_CALENDAR"); + ActivityCompat.requestPermissions(activity, new String[]{Manifest.permission.WRITE_CALENDAR}, 0); + return; + } + if (ActivityCompat.checkSelfPermission(context, Manifest.permission.READ_CALENDAR) != PackageManager.PERMISSION_GRANTED) { + if (!ActivityCompat.shouldShowRequestPermissionRationale(activity, Manifest.permission.READ_CALENDAR)) { + Log.d(TAG, "User has permanently denied permission READ_CALENDAR, informing him"); + String hint = context.getString(R.string.calendar_permission_request, "READ_CALENDAR"); + Toast toast = Toast.makeText(context, hint, Toast.LENGTH_SHORT); + toast.show(); + } + Log.d(TAG, "Ask for permission READ_CALENDAR"); + ActivityCompat.requestPermissions(activity, new String[]{Manifest.permission.READ_CALENDAR}, 0); + return; + } + + Account syncAccount = new Account(CalendarSyncAdapter.ACCOUNT_NAME, CalendarSyncAdapter.ACCOUNT_TYPE); + AccountManager accountManager = (AccountManager) context.getSystemService(ACCOUNT_SERVICE); + if (accountManager != null && !accountManager.addAccountExplicitly(syncAccount, null, null)) { + Log.e(TAG, "Error creating sync account, maybe account already exists"); + } + + Bundle settingsBundle = new Bundle(); + settingsBundle.putBoolean(ContentResolver.SYNC_EXTRAS_MANUAL, true); + settingsBundle.putBoolean(ContentResolver.SYNC_EXTRAS_EXPEDITED, true); + ContentResolver.requestSync(syncAccount, CalendarSyncAdapter.PROVIDER_NAME, settingsBundle); + Log.d(TAG, "syncCalendar() finished"); + } + catch (Throwable t) { + Log.e(TAG, "Error in syncCalendar()", t); + } + } +} diff --git a/app/src/main/java/net/moasdawiki/app/CalendarSyncAdapterService.java b/app/src/main/java/net/moasdawiki/app/CalendarSyncAdapterService.java new file mode 100644 index 0000000..66460e8 --- /dev/null +++ b/app/src/main/java/net/moasdawiki/app/CalendarSyncAdapterService.java @@ -0,0 +1,46 @@ +/* + * MoasdaWiki App + * Copyright (C) 2008 - 2020 Herbert Reiter (herbert@moasdawiki.net) + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package net.moasdawiki.app; + +import android.app.Service; +import android.content.Intent; +import android.os.IBinder; + +import org.jetbrains.annotations.Nullable; + +public class CalendarSyncAdapterService extends Service { + + private static CalendarSyncAdapter syncAdapter = null; + private static final Object syncAdapterLock = new Object(); + + @Override + public void onCreate() { + synchronized (syncAdapterLock) { + if (syncAdapter == null) { + syncAdapter = new CalendarSyncAdapter(getApplicationContext(), true, false); + } + } + } + + @Nullable + @Override + public IBinder onBind(Intent intent) { + return syncAdapter.getSyncAdapterBinder(); + } +} diff --git a/app/src/main/java/net/moasdawiki/app/Constants.java b/app/src/main/java/net/moasdawiki/app/Constants.java new file mode 100644 index 0000000..c100d52 --- /dev/null +++ b/app/src/main/java/net/moasdawiki/app/Constants.java @@ -0,0 +1,37 @@ +/* + * MoasdaWiki App + * Copyright (C) 2008 - 2020 Herbert Reiter (herbert@moasdawiki.net) + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package net.moasdawiki.app; + +/** + * Enthält zentrale Konstanten. + * + * @author Herbert Reiter + */ +public abstract class Constants { + public static final String PREFERENCES_SYNC_SERVER_HOST = "sync_server_host"; + public static final String PREFERENCES_SYNC_SERVER_PORT = "sync_server_port"; + public static final String PREFERENCES_SYNC_SERVER_NAME = "sync_server_name"; + public static final String PREFERENCES_SYNC_SERVER_VERSION = "sync_server_version"; + public static final String PREFERENCES_SYNC_SERVER_HOST_DISPLAYNAME = "sync_server_host_displayname"; + public static final String PREFERENCES_SYNC_SERVER_SESSION_ID = "sync_server_session_id"; + public static final String PREFERENCES_SYNC_CLIENT_SESSION_ID = "sync_client_session_id"; + public static final String PREFERENCES_SYNC_SERVER_SESSION_AUTHORIZED = "sync_server_session_authorized"; + public static final String PREFERENCES_SYNC_SERVER_TIME = "last_sync_server_time"; + public static final String PREFERENCES_CALENDAR_ENABLED = "calendar_enabled"; +} diff --git a/app/src/main/java/net/moasdawiki/app/MainActivity.java b/app/src/main/java/net/moasdawiki/app/MainActivity.java new file mode 100644 index 0000000..6fa90c9 --- /dev/null +++ b/app/src/main/java/net/moasdawiki/app/MainActivity.java @@ -0,0 +1,510 @@ +/* + * MoasdaWiki App + * Copyright (C) 2008 - 2020 Herbert Reiter (herbert@moasdawiki.net) + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package net.moasdawiki.app; + +import android.annotation.SuppressLint; +import android.app.AlertDialog; +import android.app.Dialog; +import android.content.Context; +import android.content.Intent; +import android.content.SharedPreferences; +import android.net.Uri; +import android.os.AsyncTask; +import android.os.Bundle; +import android.util.Log; +import android.view.KeyEvent; +import android.view.Menu; +import android.view.MenuItem; +import android.view.View; +import android.view.inputmethod.InputMethodManager; +import android.webkit.CookieManager; +import android.webkit.WebBackForwardList; +import android.webkit.WebChromeClient; +import android.webkit.WebResourceRequest; +import android.webkit.WebResourceResponse; +import android.webkit.WebSettings; +import android.webkit.WebView; +import android.webkit.WebViewClient; +import android.widget.EditText; +import android.widget.LinearLayout; +import android.widget.ProgressBar; +import android.widget.TextView; +import android.widget.Toast; + +import androidx.appcompat.app.AppCompatActivity; +import androidx.appcompat.view.menu.MenuBuilder; +import androidx.preference.PreferenceManager; + +import net.moasdawiki.base.ServiceException; +import net.moasdawiki.base.Settings; +import net.moasdawiki.plugin.Plugin; +import net.moasdawiki.plugin.PluginService; +import net.moasdawiki.server.HttpRequest; +import net.moasdawiki.server.HttpResponse; +import net.moasdawiki.service.render.HtmlService; +import net.moasdawiki.service.repository.RepositoryService; +import net.moasdawiki.util.EscapeUtils; + +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.net.InetAddress; +import java.net.URLDecoder; +import java.util.Arrays; +import java.util.Calendar; +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; + +/** + * Steuert das Verhalten des Hauptfensters inkl. eingebettetem Browser. + * + * @author Herbert Reiter + */ +public class MainActivity extends AppCompatActivity { + + private static final String TAG = "MainActivity"; + private static final String SERVER_BASE_URL = "http://localhost:1/"; + + private Settings settings; + private RepositoryService repositoryService; + private PluginService pluginService; + private HtmlService htmlService; + private SynchronizeWikiClient synchronizeWikiClient; + + private WebView webview; + + private long backButtonPressedTimestamp; + + @Override + protected void onCreate(@Nullable Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setContentView(R.layout.main_layout); + + // AndroidMainService holen + WikiEngineApplication app = (WikiEngineApplication) getApplication(); + settings = app.getServiceLocator().getSettings(); + repositoryService = app.getServiceLocator().getRepositoryService(); + pluginService = app.getServiceLocator().getPluginService(); + htmlService = app.getServiceLocator().getHtmlService(); + synchronizeWikiClient = app.getSynchronizeWikiClient(); + + // eingebetteten Browser konfigurieren + initWebView(); + + EditText searchInput = findViewById(R.id.search_input); + searchInput.setOnEditorActionListener((TextView v, int actionId, KeyEvent event) -> { + // event==null for on screen keyboard + // filter for key up from physical keyboard + if (event == null || event.getAction() == KeyEvent.ACTION_DOWN) { + onSearch(null); + return true; + } else { + return false; + } + }); + } + + /** + * Konfiguriert den eingebetteten Browser. + */ + @SuppressLint("SetJavaScriptEnabled") + private void initWebView() { + // Cookies deaktivieren + CookieManager cookieManager = CookieManager.getInstance(); + cookieManager.setAcceptCookie(false); + + // localhost-URLs in WebView anzeigen + webview = findViewById(R.id.web_browser); + webview.setWebChromeClient(new WebChromeClient()); + webview.setWebViewClient(new WebViewClient() { + /** + * Steuert, welche Links im eingebetteten Browser und welche im externen Browser + * geöffnet werden sollen. + */ + @Override + public boolean shouldOverrideUrlLoading(@NotNull WebView view, @NotNull WebResourceRequest webResourceRequest) { + Uri uri = webResourceRequest.getUrl(); + String host = uri.getHost(); + if ("localhost".equals(host)) { + // lokale URL im eingebetteten Browser öffnen + return false; + } else { + // externe Links im normalen Browser öffnen + Intent intent = new Intent(Intent.ACTION_VIEW, uri); + startActivity(intent); + return true; + } + } + + /** + * Emuliert den Wikiserver und zu vermeiden, dass ein TCP-Port geöffnet werden muss. + */ + @Override + @Nullable + public WebResourceResponse shouldInterceptRequest (@NotNull WebView view, @NotNull WebResourceRequest request) { + try { + // URL-Pfad ermitteln + Uri uri = request.getUrl(); + String encodedUrl = uri.toString(); + String url = URLDecoder.decode(encodedUrl, "UTF-8"); + if (!url.startsWith(SERVER_BASE_URL)) { + return null; // Daten per HTTP laden + } + String urlPath = url.substring(SERVER_BASE_URL.length() - 1); // ersten "/" behalten + int hashPos = urlPath.indexOf('#'); + if (hashPos >= 0) { + // cut off anchor beginning with '#' + urlPath = urlPath.substring(0, hashPos); + } + + // per URL-Mapping das zuständige Plugin aufrufen + HttpResponse response; + Plugin plugin = pluginService.getPluginByUrl(urlPath); + if (plugin != null) { + HttpRequest httpRequest = new HttpRequest(); + httpRequest.clientIP = InetAddress.getLocalHost(); + httpRequest.httpHeader = Collections.emptyMap(); + httpRequest.method = "GET"; + httpRequest.url = urlPath; + httpRequest.urlPath = urlPath; + httpRequest.urlParameters = convertParameters(uri); + httpRequest.httpBody = new byte[0]; + + response = plugin.handleRequest(httpRequest); + if (response == null) { + response = htmlService.generateErrorPage(404, "wiki.plugin.handleRequest.notsupported", plugin.getClass().getName()); + } + } else { + // unbekannte URL + response = htmlService.generateErrorPage(404, "wiki.server.url.unmapped", urlPath); + } + + // Antwortdaten einspeisen + InputStream responseData = new ByteArrayInputStream(response.getContent()); + return new WebResourceResponse(response.getContentType(), + "UTF-8", responseData); + } catch (IOException e) { + e.printStackTrace(); + } + + return null; // Daten per HTTP laden + } + + @Override + public void onPageFinished(WebView view, String url) { + super.onPageFinished(view, url); + hideProgressBar(); + } + }); + + // weitere Einstellungen + WebSettings webSettings = webview.getSettings(); + webSettings.setAllowFileAccess(false); + webSettings.setJavaScriptEnabled(true); + } + + @NotNull + private Map convertParameters(@NotNull Uri uri) { + Map result = new HashMap<>(); + for (String name : uri.getQueryParameterNames()) { + String value = uri.getQueryParameter(name); + result.put(name, value); + } + return result; + } + + @Override + public boolean onCreateOptionsMenu(@NotNull Menu menu) { + // Inflate the menu; this adds items to the action bar if it is present. + getMenuInflater().inflate(R.menu.menu_layout, menu); + if(menu instanceof MenuBuilder){ + ((MenuBuilder) menu).setOptionalIconsVisible(true); + } + return true; + } + + @Override + protected void onResume() { + super.onResume(); + updateLayoutVisibility(); + } + + @Override + public boolean onOptionsItemSelected(@NotNull MenuItem item) { + // Handle action bar item clicks here. The action bar will + // automatically handle clicks on the Home/Up button, so long + // as you specify a parent activity in AndroidManifest.xml. + + switch (item.getItemId()) { + case R.id.action_synchronize: + synchronizeServer(); + return true; + case R.id.action_startpage: + loadUrl(SERVER_BASE_URL); + return true; + case R.id.action_settings: + showSettingsDialog(); + return true; + case R.id.action_help: + String pagePathHelp = getWikiserverHelpUrl(); + loadUrl(pagePathHelp); + return true; + case R.id.action_about: + showAboutDialog(); + return true; + default: + return super.onOptionsItemSelected(item); + } + } + + @Override + public void onRequestPermissionsResult(int requestCode, @NotNull String[] permissions, @NotNull int[] grantResults) { + Log.d(TAG, "Permission granted by user: requestCode=" + requestCode + + ", permissions=" + Arrays.toString(permissions) + ", grantResults=" + Arrays.toString(grantResults)); + super.onRequestPermissionsResult(requestCode, permissions, grantResults); + } + + @Override + public void onBackPressed() { + // webview.canGoBack() liefert immer false + WebBackForwardList backForwardList = webview.copyBackForwardList(); + Log.d(TAG, "Back button pressed, backForwardList index == " + backForwardList.getCurrentIndex()); + if (backForwardList.getCurrentIndex() > 0) { + webview.goBack(); + } else { + long currentTimeMillis = System.currentTimeMillis(); + if (backButtonPressedTimestamp + 5000 < currentTimeMillis) { + // First click on back button or previous click was more than 5 seconds ago + Log.d(TAG, "Back button 1x, show close hint"); + showToast(getString(R.string.action_back_close_hint)); + backButtonPressedTimestamp = currentTimeMillis; + } else { + // Second click on back button within 5 seconds -> close app + Log.d(TAG, "Back button 2x, closing app"); + finish(); + } + } + } + + public void onConfigurationHintClicked(@SuppressWarnings("unused") View view) { + showSettingsDialog(); + } + + public void onSynchronizeHintClicked(@SuppressWarnings("unused") View view) { + synchronizeServer(); + } + + public void onSearch(@SuppressWarnings("unused") View view) { + EditText searchInput = findViewById(R.id.search_input); + String query = searchInput.getText().toString(); + query = query.trim(); + if (query.isEmpty()) { + return; + } + + // Remove focus + on screen keyboard from input field + searchInput.clearFocus(); + InputMethodManager manager = (InputMethodManager) getSystemService(Context.INPUT_METHOD_SERVICE); + if (manager != null) { + manager.hideSoftInputFromWindow(searchInput.getWindowToken(), InputMethodManager.HIDE_NOT_ALWAYS); + } + + Log.i(TAG, "Full text search for: " + query); + showProgressBarAnimation(); + String url = getWikiserverSearchUrl(query); + loadUrl(url); + + // Der Wartedialog wird nach dem Anzeigen der Suchergebnisse durch Aufruf von + // closeProgressDialog() geschlossen. + } + + private void showSettingsDialog() { + Intent intent = new Intent(this, SettingsActivity.class); + startActivity(intent); + } + + private void showAboutDialog() { + // Versionsnummer einsetzen + @SuppressLint("InflateParams") + View dialogView = getLayoutInflater().inflate(R.layout.about_layout, null); + TextView versionText = dialogView.findViewById(R.id.label_version); + versionText.setText(getString(R.string.about_version, settings.getVersion())); + int year = Calendar.getInstance().get(Calendar.YEAR); + TextView copyrightText = dialogView.findViewById(R.id.label_copyright); + copyrightText.setText(getString(R.string.about_copyright, year)); + + // Dialog anzeigen + AlertDialog.Builder builder = new AlertDialog.Builder(this); + builder.setView(dialogView); + builder.setTitle(R.string.app_name); + builder.setIcon(R.mipmap.ic_cow); + builder.setPositiveButton(R.string.button_close, (dialog, id) -> {}); + Dialog dialog = builder.create(); + dialog.show(); + } + + private void synchronizeServer() { + SyncNowTask syncNowTask = new SyncNowTask(); + syncNowTask.execute(); + } + + /** + * Öffnet die angegebene URL im eingebetteten Browser. + */ + private void loadUrl(@NotNull String url) { + Log.d(TAG, "Open URL " + url); + webview.loadUrl(url); + } + + @NotNull + private String getWikiserverSearchUrl(@NotNull String query) { + return SERVER_BASE_URL + "search/?text=" + EscapeUtils.encodeUrlParameter(query); + } + + @NotNull + private String getWikiserverHelpUrl() { + return SERVER_BASE_URL + "view/wiki/"; + } + + private void updateLayoutVisibility() { + // Show/hide hint on unconfigured host name + LinearLayout hostUnconfiguredHint = findViewById(R.id.hint_host_unconfigured); + SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(this); + String host = preferences.getString(Constants.PREFERENCES_SYNC_SERVER_HOST, null); + boolean hostUnconfiguredHintVisible = (host == null || host.isEmpty()); + if (hostUnconfiguredHintVisible) { + hostUnconfiguredHint.setVisibility(View.VISIBLE); + } else { + hostUnconfiguredHint.setVisibility(View.GONE); + } + + // Show/hide hint on synchronization + LinearLayout repositoryEmptyHint = findViewById(R.id.hint_repository_empty); + boolean repositoryEmptyHintVisible = (!hostUnconfiguredHintVisible && repositoryService.getFiles().size() < 3); + if (repositoryEmptyHintVisible) { + repositoryEmptyHint.setVisibility(View.VISIBLE); + } else { + repositoryEmptyHint.setVisibility(View.GONE); + } + + // Show/hide search bar and wiki view + LinearLayout searchArea = findViewById(R.id.search_area); + WebView webView = findViewById(R.id.web_browser); + boolean webViewVisible = !hostUnconfiguredHintVisible && !repositoryEmptyHintVisible; + if (webViewVisible) { + searchArea.setVisibility(View.VISIBLE); + boolean previousVisible = (webView.getVisibility() == View.INVISIBLE); + webView.setVisibility(View.VISIBLE); + + // if WebView is shown the first time, go to start page + if (!previousVisible) { + loadUrl(SERVER_BASE_URL); + } + } else { + searchArea.setVisibility(View.GONE); + webView.setVisibility(View.GONE); + } + } + + private void showProgressBarAnimation() { + ProgressBar progressBar = findViewById(R.id.search_progressbar); + progressBar.setIndeterminate(true); + progressBar.setVisibility(View.VISIBLE); + } + + private void showProgressBar(int progress, int max) { + ProgressBar progressBar = findViewById(R.id.search_progressbar); + progressBar.setIndeterminate(false); + progressBar.setMin(0); + progressBar.setMax(max); + progressBar.setProgress(progress); + progressBar.setVisibility(View.VISIBLE); + } + + private void hideProgressBar() { + ProgressBar progressBar = findViewById(R.id.search_progressbar); + progressBar.setVisibility(View.GONE); + } + + private void showToast(String message) { + Toast toast = Toast.makeText(this, message, Toast.LENGTH_SHORT); + toast.show(); + } + + /** + * Synchronisiert mit dem Wikiserver in einem separaten Thread. + */ + @SuppressLint("StaticFieldLeak") + @SuppressWarnings("NonStaticInnerClassInSecureContext") + private class SyncNowTask extends AsyncTask implements SynchronizeWikiClient.ProgressFeedback { + + @Nullable + @Override + protected Integer doInBackground(Void... params) { + try { + return synchronizeWikiClient.synchronizeRepository(this); + } catch (ServiceException e) { + Log.e(TAG, "Error synchronizing repository", e); + } + return null; + } + + @Override + public void progress(int step, int total) { + ProgressData progressData = new ProgressData(); + progressData.step = step; + progressData.total = total; + publishProgress(progressData); + } + + @Override + protected void onProgressUpdate(ProgressData... progressData) { + showProgressBar(progressData[0].step, progressData[0].total); + } + + @Override + protected void onPostExecute(@Nullable Integer filesCount) { + CalendarSyncAdapter.requestCalendarSync(MainActivity.this); + + if (filesCount == null) { + showToast(getString(R.string.settings_synchronize_failed)); + } else if (filesCount > 0) { + showToast(getString(R.string.settings_synchronize_successful, filesCount)); + + // Reset internal file caches + WikiEngineApplication app = (WikiEngineApplication) getApplication(); + app.resetServices(); + } else { + showToast(getString(R.string.settings_synchronize_not_necessary)); + } + + hideProgressBar(); + updateLayoutVisibility(); + } + } + + private static class ProgressData { + public int step; + public int total; + } +} diff --git a/app/src/main/java/net/moasdawiki/app/SettingsActivity.java b/app/src/main/java/net/moasdawiki/app/SettingsActivity.java new file mode 100644 index 0000000..96ede1b --- /dev/null +++ b/app/src/main/java/net/moasdawiki/app/SettingsActivity.java @@ -0,0 +1,42 @@ +/* + * MoasdaWiki App + * Copyright (C) 2008 - 2020 Herbert Reiter (herbert@moasdawiki.net) + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package net.moasdawiki.app; + + +import android.os.Bundle; + +import androidx.appcompat.app.AppCompatActivity; + +import org.jetbrains.annotations.Nullable; + +/** + * Settings dialog + */ +public class SettingsActivity extends AppCompatActivity { + + @Override + protected void onCreate(@Nullable Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setContentView(R.layout.settings_layout); + getSupportFragmentManager() + .beginTransaction() + .replace(R.id.settings_layout, new SettingsFragment()) + .commit(); + } +} diff --git a/app/src/main/java/net/moasdawiki/app/SettingsFragment.java b/app/src/main/java/net/moasdawiki/app/SettingsFragment.java new file mode 100644 index 0000000..cd02f47 --- /dev/null +++ b/app/src/main/java/net/moasdawiki/app/SettingsFragment.java @@ -0,0 +1,203 @@ +/* + * MoasdaWiki App + * Copyright (C) 2008 - 2020 Herbert Reiter (herbert@moasdawiki.net) + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package net.moasdawiki.app; + +import android.annotation.SuppressLint; +import android.content.SharedPreferences; +import android.os.AsyncTask; +import android.os.Bundle; +import android.text.InputFilter; +import android.text.InputType; +import android.util.Log; +import android.widget.Toast; + +import androidx.annotation.Nullable; +import androidx.preference.EditTextPreference; +import androidx.preference.Preference; +import androidx.preference.PreferenceFragmentCompat; + +import net.moasdawiki.base.Settings; +import net.moasdawiki.service.repository.RepositoryService; + +import org.jetbrains.annotations.NotNull; + +import java.text.SimpleDateFormat; +import java.util.Date; +import java.util.Locale; + +public class SettingsFragment extends PreferenceFragmentCompat implements SharedPreferences.OnSharedPreferenceChangeListener { + + private static final String TAG = "SettingsFragment"; + private SynchronizeWikiClient synchronizeWikiClient; + private RepositoryService repositoryService; + private Settings settings; + + @Override + public void onCreate(@Nullable Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + //noinspection ConstantConditions + WikiEngineApplication app = (WikiEngineApplication) getContext().getApplicationContext(); + synchronizeWikiClient = app.getSynchronizeWikiClient(); + repositoryService = app.getServiceLocator().getRepositoryService(); + settings = app.getServiceLocator().getSettings(); + } + + @Override + public void onCreatePreferences(Bundle savedInstanceState, @Nullable String rootKey) { + setPreferencesFromResource(R.xml.settings_fragment, rootKey); + + EditTextPreference preference = findPreference("sync_server_port"); + if (preference != null) { + preference.setOnBindEditTextListener(editText -> { + editText.setInputType(InputType.TYPE_CLASS_NUMBER); + editText.setFilters(new InputFilter[]{new InputFilter.LengthFilter(5)}); + }); + } + } + + @Override + public void onResume() { + super.onResume(); + getPreferenceManager().getSharedPreferences().registerOnSharedPreferenceChangeListener(this); + updateSettingsSummaries(); + updateStatusText(); + } + + @Override + public void onPause() { + super.onPause(); + getPreferenceManager().getSharedPreferences().unregisterOnSharedPreferenceChangeListener(this); + } + + @Override + public void onSharedPreferenceChanged(SharedPreferences sharedPreferences, String key) { + updateSettingsSummaries(); + + if ("sync_server_host".equals(key) || "sync_server_port".equals(key)) { + checkServerConnection(); + } + } + + private void showToast(String message) { + Toast toast = Toast.makeText(getContext(), message, Toast.LENGTH_SHORT); + toast.show(); + } + + private void updateSettingsSummaries() { + // Host name + SharedPreferences preferences = getPreferenceManager().getSharedPreferences(); + String serverHost = preferences.getString(Constants.PREFERENCES_SYNC_SERVER_HOST, null); + EditTextPreference syncServerHost = findPreference("sync_server_host"); + if (syncServerHost != null) { + if (serverHost != null && !serverHost.isEmpty()) { + syncServerHost.setSummary(serverHost); + } else { + syncServerHost.setSummary(R.string.settings_host_summary_empty); + } + } + + // Port + int serverPort = preferences.getInt(Constants.PREFERENCES_SYNC_SERVER_PORT, 0); + EditTextPreference syncServerPort = findPreference("sync_server_port"); + if (syncServerPort != null) { + if (serverPort > 0) { + syncServerPort.setSummary(Integer.toString(serverPort)); + } else { + String summary = getString(R.string.settings_port_summary_empty, settings.getServerPort()); + syncServerPort.setSummary(summary); + } + } + } + + private void updateStatusText() { + SharedPreferences preferences = getPreferenceManager().getSharedPreferences(); + String serverSessionId = preferences.getString(Constants.PREFERENCES_SYNC_SERVER_SESSION_ID, null); + boolean serverSessionAuthorized = preferences.getBoolean(Constants.PREFERENCES_SYNC_SERVER_SESSION_AUTHORIZED, false); + String serverHost = preferences.getString(Constants.PREFERENCES_SYNC_SERVER_HOST, null); + String serverHostDisplayName = preferences.getString(Constants.PREFERENCES_SYNC_SERVER_HOST_DISPLAYNAME, ""); + + // Connection status + String connectionStatus; + if (serverSessionId == null) { + connectionStatus = getString(R.string.settings_status_server_not_connected); + } else if (!serverSessionAuthorized) { + connectionStatus = getString(R.string.settings_status_server_authorization_mission); + } else { + connectionStatus = getString(R.string.settings_status_server_connected); + } + + // Server Host + String serverHostCombined = serverHostDisplayName; + if (!serverHostCombined.isEmpty() && serverHost != null) { + serverHostCombined += " (" + serverHost + ")"; + } + + // Last synchronization timestamp + long lastSyncTimeMs = preferences.getLong(Constants.PREFERENCES_SYNC_SERVER_TIME, 0); + String lastSyncStr = ""; + if (lastSyncTimeMs > 0) { + SimpleDateFormat df = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.ENGLISH); + lastSyncStr = df.format(new Date(lastSyncTimeMs)); + } + + // Number of files in repository + int filesCount = repositoryService.getFiles().size(); + String filesCountStr = Integer.toString(filesCount); + + String htmlText = getString(R.string.settings_status_details, connectionStatus, serverHostCombined, lastSyncStr, filesCountStr); + Preference synchronizationStatus = findPreference("synchronization_status"); + if (synchronizationStatus != null) { + synchronizationStatus.setSummary(htmlText); + } + } + + private void checkServerConnection() { + new ConnectServerTask().execute(); + } + + /** + * Checks the connection to the Wiki server. + */ + @SuppressLint("StaticFieldLeak") + @SuppressWarnings("NonStaticInnerClassInSecureContext") + private class ConnectServerTask extends AsyncTask { + @Override + @NotNull + protected Boolean doInBackground(Void... params) { + SharedPreferences preferences = getPreferenceManager().getSharedPreferences(); + String host = preferences.getString(Constants.PREFERENCES_SYNC_SERVER_HOST, null); + if (host == null) { + Log.d(TAG, "Cancel ConnectServerTask because server host is not configured"); + return false; + } + + // Connect with server + return synchronizeWikiClient.createAndCheckSession(); + } + + @Override + protected void onPostExecute(@NotNull Boolean success) { + updateStatusText(); + + if (!success) { + showToast(getString(R.string.settings_search_failed)); + } + } + } +} diff --git a/app/src/main/java/net/moasdawiki/app/SynchronizeWikiClient.java b/app/src/main/java/net/moasdawiki/app/SynchronizeWikiClient.java new file mode 100644 index 0000000..12d8e7c --- /dev/null +++ b/app/src/main/java/net/moasdawiki/app/SynchronizeWikiClient.java @@ -0,0 +1,466 @@ +/* + * MoasdaWiki App + * Copyright (C) 2008 - 2020 Herbert Reiter (herbert@moasdawiki.net) + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package net.moasdawiki.app; + +import android.content.Context; +import android.content.SharedPreferences; +import android.os.Build; +import android.util.Base64; +import android.util.Log; + +import androidx.preference.PreferenceManager; + +import net.moasdawiki.base.Logger; +import net.moasdawiki.base.ServiceException; +import net.moasdawiki.base.Settings; +import net.moasdawiki.plugin.ServiceLocator; +import net.moasdawiki.plugin.sync.AbstractSyncXml; +import net.moasdawiki.plugin.sync.CheckSessionResponseXml; +import net.moasdawiki.plugin.sync.CheckSessionXml; +import net.moasdawiki.plugin.sync.CreateSessionResponseXml; +import net.moasdawiki.plugin.sync.CreateSessionXml; +import net.moasdawiki.plugin.sync.ErrorResponseXml; +import net.moasdawiki.plugin.sync.ListModifiedFilesResponseXml; +import net.moasdawiki.plugin.sync.ListModifiedFilesXml; +import net.moasdawiki.plugin.sync.ReadFileResponseXml; +import net.moasdawiki.plugin.sync.ReadFileXml; +import net.moasdawiki.plugin.sync.SingleFileXml; +import net.moasdawiki.service.repository.AnyFile; +import net.moasdawiki.service.repository.RepositoryService; +import net.moasdawiki.util.DateUtils; +import net.moasdawiki.util.xml.XmlGenerator; +import net.moasdawiki.util.xml.XmlParser; + +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.io.ByteArrayOutputStream; +import java.io.InputStream; +import java.io.OutputStream; +import java.math.BigInteger; +import java.net.HttpURLConnection; +import java.net.InetAddress; +import java.net.NetworkInterface; +import java.net.SocketException; +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.security.SecureRandom; +import java.util.Date; +import java.util.Enumeration; + +/** + * Sucht einen Wikiserver in Netzwerk und synchronisiert alle Wikidateien + * im eigenen Repository.. + * + * @author Herbert Reiter + */ +public class SynchronizeWikiClient { + + private static final String TAG = "SynchronizeWikiClient"; + + private static final String PROTOCOL_VERSION = "2.0"; + + @NotNull + private final Context mContext; + @NotNull + private final Logger logger; + @NotNull + private final Settings settings; + @NotNull + private final RepositoryService repositoryService; + @NotNull + private final SecureRandom random = new SecureRandom(); + + public SynchronizeWikiClient(@NotNull Context mContext, @NotNull ServiceLocator serviceLocator) { + this.mContext = mContext; + this.logger = serviceLocator.getLogger(); + this.settings = serviceLocator.getSettings(); + this.repositoryService = serviceLocator.getRepositoryService(); + } + + /** + * Verbindet sich mit dem Wikiserver. Ist bereits eine Serversession vorhanden, wird diese + * weiter verwendet. + */ + public boolean createAndCheckSession() { + PreferenceManager.getDefaultSharedPreferences(mContext); + SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(mContext); + String host = preferences.getString(Constants.PREFERENCES_SYNC_SERVER_HOST, null); + int port = preferences.getInt(Constants.PREFERENCES_SYNC_SERVER_PORT, settings.getServerPort()); + String serverSessionId = preferences.getString(Constants.PREFERENCES_SYNC_SERVER_SESSION_ID, null); + + // Host konfiguriert? + if (host == null) { + return false; + } + String serverHostPort = host + ':' + port; + + try { + // Wenn noch keine Session vorhanden ist, eine erzeugen + boolean createSessionCalled = false; + if (serverSessionId == null) { + createSessionCalled = true; + createSession(serverHostPort); + } + + // Ist Session freigeschaltet zur Synchronisierung? + boolean[] checkResult = checkSession(serverHostPort); + if (checkResult[0]) { + return true; + } + + // Session ist ungültig --> neue Session holen + if (createSessionCalled) { + // createSession nicht erneut aufrufen + return false; + } + createSession(serverHostPort); + + // Session erneut prüfen + boolean[] checkResult2 = checkSession(serverHostPort); + return checkResult2[0]; + } + catch (ServiceException e) { + return false; + } + } + + private void createSession(@NotNull String serverHostPort) throws ServiceException { + // Anfrage schicken + CreateSessionXml createSessionXml = new CreateSessionXml(); + createSessionXml.version = PROTOCOL_VERSION; + createSessionXml.clientSessionId = generateSessionId(); + createSessionXml.clientName = "MoasdaWiki-App"; + createSessionXml.clientVersion = settings.getVersion(); + createSessionXml.clientHost = getDeviceName(); + String hostName = getLocalHostname(); + if (hostName != null) { + createSessionXml.clientHost += " / " + hostName; + } + String requestXml = generateXml(createSessionXml); + String responseXml = sendXmlRequest(serverHostPort, "/sync/create-session", requestXml); + + // Antwort auswerten + CreateSessionResponseXml response = parseXml(responseXml, CreateSessionResponseXml.class); + Log.d(TAG, "Current sync session ID '" + response.serverSessionId + "'"); + + // Session prüfen + if (response.serverSessionId == null) { + throw new ServiceException("Didn't get a server session ID"); + } + + SharedPreferences settings = PreferenceManager.getDefaultSharedPreferences(mContext); + SharedPreferences.Editor editor = settings.edit(); + editor.putString(Constants.PREFERENCES_SYNC_SERVER_NAME, response.serverName); + editor.putString(Constants.PREFERENCES_SYNC_SERVER_VERSION, response.serverVersion); + editor.putString(Constants.PREFERENCES_SYNC_SERVER_HOST_DISPLAYNAME, response.serverHost); + editor.putString(Constants.PREFERENCES_SYNC_SERVER_SESSION_ID, response.serverSessionId); + editor.putString(Constants.PREFERENCES_SYNC_CLIENT_SESSION_ID, createSessionXml.clientSessionId); + // Synchronisierungszeiten können nicht mehr genutzt werden + editor.remove(Constants.PREFERENCES_SYNC_SERVER_TIME); + editor.remove(Constants.PREFERENCES_SYNC_SERVER_SESSION_AUTHORIZED); + editor.apply(); + } + + private String generateSessionId() { + return new BigInteger(130, random).toString(32); + } + + @NotNull + private boolean[] checkSession(@NotNull String serverHostPort) throws ServiceException { + SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(mContext); + String serverSessionId = preferences.getString(Constants.PREFERENCES_SYNC_SERVER_SESSION_ID, null); + String clientSessionId = preferences.getString(Constants.PREFERENCES_SYNC_CLIENT_SESSION_ID, null); + + // Anfrage schicken + CheckSessionXml checkSessionXml = new CheckSessionXml(); + checkSessionXml.version = PROTOCOL_VERSION; + checkSessionXml.serverSessionId = serverSessionId; + String requestXml = generateXml(checkSessionXml); + String responseXml = sendXmlRequest(serverHostPort, "/sync/check-session", requestXml); + + // Antwort auswerten + CheckSessionResponseXml response = parseXml(responseXml, CheckSessionResponseXml.class); + if (response.valid == null || !response.valid) { + Log.d(TAG, "Sync server session ID '" + serverSessionId + "' is not valid any more"); + SharedPreferences.Editor editor = preferences.edit(); + editor.remove(Constants.PREFERENCES_SYNC_SERVER_SESSION_ID); + editor.remove(Constants.PREFERENCES_SYNC_CLIENT_SESSION_ID); + editor.apply(); + return new boolean[]{ false, false }; + } + if (clientSessionId != null && !clientSessionId.equals(response.clientSessionId)) { + Log.d(TAG, "Sync server authentication failed, client session ID does not match"); + SharedPreferences.Editor editor = preferences.edit(); + editor.remove(Constants.PREFERENCES_SYNC_SERVER_SESSION_ID); + editor.remove(Constants.PREFERENCES_SYNC_CLIENT_SESSION_ID); + editor.apply(); + return new boolean[]{ false, false }; + } + + SharedPreferences.Editor editor = preferences.edit(); + boolean authorized = response.authorized != null && response.authorized; + editor.putBoolean(Constants.PREFERENCES_SYNC_SERVER_SESSION_AUTHORIZED, authorized); + editor.apply(); + return new boolean[]{ true, authorized }; + } + + /** + * Synchronisiert alle Repository-Dateien mit dem Wikiserver. Vorher wird geprüft, ob + * die Server-Session noch gültig ist und wir mit demselben Server verbunden sind. + * + * @return Anzahl der synchronisierten Dateien. + */ + public int synchronizeRepository(ProgressFeedback feedback) throws ServiceException { + SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(mContext); + String host = preferences.getString(Constants.PREFERENCES_SYNC_SERVER_HOST, null); + int port = preferences.getInt(Constants.PREFERENCES_SYNC_SERVER_PORT, settings.getServerPort()); + if (host == null) { + throw new ServiceException("No wiki server configured"); + } + String serverSessionId = preferences.getString(Constants.PREFERENCES_SYNC_SERVER_SESSION_ID, null); + if (serverSessionId == null) { + throw new ServiceException("Not server session ID found"); + } + long lastSyncServerTimeMs = preferences.getLong(Constants.PREFERENCES_SYNC_SERVER_TIME, 0); + Date lastSyncServerTime = null; + if (lastSyncServerTimeMs > 0) { + lastSyncServerTime = new Date(lastSyncServerTimeMs); + } + + // Aktuelle Session überprüfen + String serverHostPort = host + ':' + port; + boolean[] checkResult = checkSession(serverHostPort); + if (!checkResult[1]) { + throw new ServiceException("Client not authenticated at wiki server"); + } + + // Anfrage schicken + ListModifiedFilesXml listModifiedFilesXml = new ListModifiedFilesXml(); + listModifiedFilesXml.version = PROTOCOL_VERSION; + listModifiedFilesXml.serverSessionId = serverSessionId; + listModifiedFilesXml.lastSyncServerTime = DateUtils.formatUtcDate(lastSyncServerTime); + String requestXml = generateXml(listModifiedFilesXml); + String responseXml = sendXmlRequest(serverHostPort, "/sync/list-modified-files", requestXml); + + // Antwort auswerten + ListModifiedFilesResponseXml response = parseXml(responseXml, ListModifiedFilesResponseXml.class); + int fileCount = response.fileList.size(); + Log.d(TAG, "Downloading " + fileCount + " files from server"); + if (fileCount == 0) { + // no files to download, cancel process + return 0; + } + + for (int i = 0; i < fileCount && !feedback.isCancelled(); i++) { + feedback.progress(i, fileCount); + SingleFileXml serverFile = response.fileList.get(i); + try { + downloadFileFromServer(serverHostPort, serverSessionId, serverFile.filePath); + } + catch (ServiceException e) { + Log.w(TAG, "Error reading file from server, ignoring it", e); + } + } + + // Reset internal caches + WikiEngineApplication app = (WikiEngineApplication) mContext.getApplicationContext(); + app.resetServices(); + + // Update last sync time + if (!feedback.isCancelled()) { + Date currentServerDate = DateUtils.parseUtcDate(response.currentServerTime); + if (currentServerDate != null) { + long newLastSyncServerTimeMs = currentServerDate.getTime(); + SharedPreferences.Editor editor = preferences.edit(); + editor.putLong(Constants.PREFERENCES_SYNC_SERVER_TIME, newLastSyncServerTimeMs); + editor.apply(); + } + } + + return fileCount; + } + + private void downloadFileFromServer(@NotNull String serverHostPort, @NotNull String serverSessionId, @NotNull String filePath) throws ServiceException { + // Anfrage schicken + ReadFileXml readFileXml = new ReadFileXml(); + readFileXml.version = PROTOCOL_VERSION; + readFileXml.serverSessionId = serverSessionId; + readFileXml.filePath = filePath; + String requestXml = generateXml(readFileXml); + String responseXml = sendXmlRequest(serverHostPort, "/sync/read-file", requestXml); + + // Datei aus Antwort speichern + ReadFileResponseXml response = parseXml(responseXml, ReadFileResponseXml.class); + byte[] fileContent = Base64.decode(response.content, Base64.NO_WRAP); + Date fileTimestamp = DateUtils.parseUtcDate(response.timestamp); + AnyFile anyFile = new AnyFile(filePath); + repositoryService.writeBinaryFile(anyFile, fileContent, fileTimestamp); + Log.d(TAG, "File '" + filePath + "' replaced by newer content from server"); + } + + /** + * Versucht den Wifi-Hostnamen zu ermitteln und gibt ihn zurück. + */ + @Nullable + private String getLocalHostname() { + try { + String wlanHostname = null; + String ethHostname = null; + Enumeration networkInterfaces = NetworkInterface.getNetworkInterfaces(); + while (networkInterfaces.hasMoreElements()) { + NetworkInterface ni = networkInterfaces.nextElement(); + Enumeration inetAddresses = ni.getInetAddresses(); + while (inetAddresses.hasMoreElements()) { + InetAddress ia = inetAddresses.nextElement(); + // Indikator: Hostname und Adresse sind unterschiedlich + // http://stackoverflow.com/questions/21898456/get-android-wifi-net-hostname-from-code + if (!ia.getCanonicalHostName().equals(ia.getHostAddress())) { + if ("wlan0".equals(ni.getDisplayName())) { + wlanHostname = ia.getCanonicalHostName(); + } else if ("eth0".equals(ia.getHostAddress())) { + ethHostname = ia.getCanonicalHostName(); + } + } + } + } + if (wlanHostname != null) { + Log.d(TAG, "Hostname (wlan0): " + wlanHostname); + return wlanHostname; + } + if (ethHostname != null) { + Log.d(TAG, "Hostname (eth0): " + ethHostname); + return ethHostname; + } + Log.d(TAG, "No hostname found"); + } catch (SocketException e) { + Log.e(TAG, "Error determining hostname", e); + } + return null; + } + + @NotNull + private String getDeviceName() { + String manufacturer = Build.MANUFACTURER; + String model = Build.MODEL; + if (model.startsWith(manufacturer)) { + return capitalize(model); + } else { + return capitalize(manufacturer) + " " + model; + } + } + + private String capitalize(String s) { + if (s == null || s.isEmpty()) { + return ""; + } + return Character.toUpperCase(s.charAt(0)) + s.substring(1); + } + + /** + * Schickt einen XML-Request und liest die XML-Antwort ein. + * + * @param urlPath HTTP-URL, nicht null + * @param requestXml XML-Anfrage, nicht null. + * @return XML-Antwort, nicht null. + */ + @NotNull + private String sendXmlRequest(@NotNull String serverHostPort, @NotNull String urlPath, @NotNull String requestXml) throws ServiceException { + try { + String url = "http://" + serverHostPort + urlPath; + Log.d(TAG, "Request to " + url + ": " + truncateLogText(requestXml, 200)); + byte[] requestBytes = requestXml.getBytes(StandardCharsets.UTF_8); + + URI uri = new URI(url); + HttpURLConnection conn = (HttpURLConnection) uri.toURL().openConnection(); + conn.setRequestMethod("POST"); + conn.setRequestProperty("Content-Type", "text/xml"); + conn.setRequestProperty("Content-Length", Integer.toString(requestBytes.length)); + conn.setUseCaches(false); + conn.setDoOutput(true); + conn.setDoInput(true); + conn.setConnectTimeout(1000); // 1 Sekunde + conn.setReadTimeout(10000); // 10 Sekunden + conn.connect(); + + OutputStream out = conn.getOutputStream(); + out.write(requestBytes); + out.flush(); + + InputStream in = conn.getInputStream(); + ByteArrayOutputStream byteStream = new ByteArrayOutputStream(in.available()); + int bytesRead; + byte[] buffer = new byte[1024]; + while ((bytesRead = in.read(buffer)) != -1) { + byteStream.write(buffer, 0, bytesRead); + } + byte[] responseBytes = byteStream.toByteArray(); + + String responseXml = new String(responseBytes, StandardCharsets.UTF_8); + Log.d(TAG, "Response: " + truncateLogText(responseXml, 400)); + return responseXml; + } catch (Exception e) { + Log.e(TAG, "Error sending XML request", e); + throw new ServiceException("Error sending XML request", e); + } + } + + private String truncateLogText(String logText, int maxLength) { + if (logText.length() <= maxLength) { + return logText; + } + return logText.substring(0, maxLength) + '…'; + } + + /** + * Wandelt eine JAXB-Bean in einen XML-Strom um. + */ + @NotNull + private String generateXml(@NotNull AbstractSyncXml xmlBean) throws ServiceException { + XmlGenerator xmlGenerator = new XmlGenerator(logger); + return xmlGenerator.generate(xmlBean); + } + + /** + * Wandelt einen XML-Strom in eine JAXB-Bean um. + */ + @NotNull + private T parseXml(@NotNull String xml, @NotNull Class xmlBeanType) throws ServiceException { + try { + XmlParser xmlParser = new XmlParser(logger); + return xmlParser.parse(xml, xmlBeanType); + } catch (ServiceException e) { + logger.write("Failed to parse XML for class " + xmlBeanType.getSimpleName() + ", try class ErrorResponseXml", e); + // Versuche eine Fehlerantwort zu parsen + XmlParser xmlParser = new XmlParser(logger); + ErrorResponseXml errorResponseXml = xmlParser.parse(xml, ErrorResponseXml.class); + throw new ServiceException(errorResponseXml.message); + } + } + + /** + * Interface, um den Fortschritt während der Synchronisierung mit dem Server + * zurückzuübermitteln. + */ + public interface ProgressFeedback { + @SuppressWarnings("BooleanMethodIsAlwaysInverted") + boolean isCancelled(); + void progress(int step, int total); + } +} diff --git a/app/src/main/java/net/moasdawiki/app/WikiEngineApplication.java b/app/src/main/java/net/moasdawiki/app/WikiEngineApplication.java new file mode 100644 index 0000000..6ee60d4 --- /dev/null +++ b/app/src/main/java/net/moasdawiki/app/WikiEngineApplication.java @@ -0,0 +1,93 @@ +/* + * MoasdaWiki App + * Copyright (C) 2008 - 2020 Herbert Reiter (herbert@moasdawiki.net) + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package net.moasdawiki.app; + +import android.app.Application; + +import net.moasdawiki.base.Logger; +import net.moasdawiki.base.Messages; +import net.moasdawiki.base.Settings; +import net.moasdawiki.plugin.PluginService; +import net.moasdawiki.plugin.ServiceLocator; +import net.moasdawiki.service.render.HtmlService; +import net.moasdawiki.service.repository.FilesystemRepositoryService; +import net.moasdawiki.service.repository.RepositoryService; +import net.moasdawiki.service.search.SearchService; +import net.moasdawiki.service.wiki.WikiService; +import net.moasdawiki.service.wiki.WikiServiceImpl; + +import java.io.File; + +/** + * Verwaltet den Lebenszyklus der Wiki Engine. Muss außerhalb der Activities erfolgen, weil diese + * z.B. beim Drehen des Bildschirm neu erzeugt werden. + * + * @author Herbert Reiter + */ +public class WikiEngineApplication extends Application { + + private Logger logger; + private ServiceLocator serviceLocator; + private SynchronizeWikiClient synchronizeWikiClient; + + @Override + public void onCreate() { + super.onCreate(); + + logger = new Logger(); + logger.write("MoasdaWiki app starting"); + + File internalStorageRepositoryRoot = new File(getFilesDir(), "repository"); + RepositoryService repositoryService = new FilesystemRepositoryService(logger, internalStorageRepositoryRoot); + repositoryService.init(); + WikiService wikiService = new WikiServiceImpl(logger, repositoryService); + SearchService searchService = new SearchService(logger, wikiService); + Settings settings = new AndroidSettings(logger, repositoryService, AndroidSettings.getConfigFileApp()); + Messages messages = new Messages(logger, settings, repositoryService); + PluginService pluginService = new PluginService(logger, settings); + HtmlService htmlService = new HtmlService(logger, settings, messages, wikiService, pluginService); + serviceLocator = new ServiceLocator(logger, settings, messages, repositoryService, wikiService, htmlService, searchService, pluginService); + + pluginService.loadPlugins(serviceLocator); + + synchronizeWikiClient = new SynchronizeWikiClient(this, serviceLocator); + } + + public void resetServices() { + serviceLocator.getRepositoryService().rebuildCache(); + serviceLocator.getWikiService().reset(); + serviceLocator.getSettings().reset(); + serviceLocator.getMessages().reset(); + serviceLocator.getPluginService().loadPlugins(serviceLocator); + } + + @Override + public void onTerminate() { + logger.write("MoasdaWiki app stopping"); + super.onTerminate(); + } + + public ServiceLocator getServiceLocator() { + return serviceLocator; + } + + public SynchronizeWikiClient getSynchronizeWikiClient() { + return synchronizeWikiClient; + } +} diff --git a/app/src/main/res/drawable-anydpi/ic_action_event.xml b/app/src/main/res/drawable-anydpi/ic_action_event.xml new file mode 100644 index 0000000..4ade0e8 --- /dev/null +++ b/app/src/main/res/drawable-anydpi/ic_action_event.xml @@ -0,0 +1,11 @@ + + + diff --git a/app/src/main/res/drawable-anydpi/ic_action_sync.xml b/app/src/main/res/drawable-anydpi/ic_action_sync.xml new file mode 100644 index 0000000..45fc1b4 --- /dev/null +++ b/app/src/main/res/drawable-anydpi/ic_action_sync.xml @@ -0,0 +1,11 @@ + + + diff --git a/app/src/main/res/drawable-anydpi/ic_action_sync_white.xml b/app/src/main/res/drawable-anydpi/ic_action_sync_white.xml new file mode 100644 index 0000000..2f3d983 --- /dev/null +++ b/app/src/main/res/drawable-anydpi/ic_action_sync_white.xml @@ -0,0 +1,11 @@ + + + diff --git a/app/src/main/res/drawable-hdpi/ic_action_event.png b/app/src/main/res/drawable-hdpi/ic_action_event.png new file mode 100644 index 0000000..b914161 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_action_event.png differ diff --git a/app/src/main/res/drawable-hdpi/ic_action_sync.png b/app/src/main/res/drawable-hdpi/ic_action_sync.png new file mode 100644 index 0000000..92ec8fc Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_action_sync.png differ diff --git a/app/src/main/res/drawable-hdpi/ic_action_sync_white.png b/app/src/main/res/drawable-hdpi/ic_action_sync_white.png new file mode 100644 index 0000000..0cae6ac Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_action_sync_white.png differ diff --git a/app/src/main/res/drawable-hdpi/ic_menu_help.png b/app/src/main/res/drawable-hdpi/ic_menu_help.png new file mode 100644 index 0000000..473b372 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_menu_help.png differ diff --git a/app/src/main/res/drawable-hdpi/ic_menu_home.png b/app/src/main/res/drawable-hdpi/ic_menu_home.png new file mode 100644 index 0000000..3baf1ca Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_menu_home.png differ diff --git a/app/src/main/res/drawable-hdpi/ic_menu_info_details.png b/app/src/main/res/drawable-hdpi/ic_menu_info_details.png new file mode 100644 index 0000000..6a7a1e9 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_menu_info_details.png differ diff --git a/app/src/main/res/drawable-hdpi/ic_menu_preferences.png b/app/src/main/res/drawable-hdpi/ic_menu_preferences.png new file mode 100644 index 0000000..5321f82 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_menu_preferences.png differ diff --git a/app/src/main/res/drawable-hdpi/ic_menu_search.png b/app/src/main/res/drawable-hdpi/ic_menu_search.png new file mode 100644 index 0000000..ae2f44b Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_menu_search.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_action_event.png b/app/src/main/res/drawable-mdpi/ic_action_event.png new file mode 100644 index 0000000..a611945 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_action_event.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_action_sync.png b/app/src/main/res/drawable-mdpi/ic_action_sync.png new file mode 100644 index 0000000..88cdf46 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_action_sync.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_action_sync_white.png b/app/src/main/res/drawable-mdpi/ic_action_sync_white.png new file mode 100644 index 0000000..8c2eef8 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_action_sync_white.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_menu_help.png b/app/src/main/res/drawable-mdpi/ic_menu_help.png new file mode 100644 index 0000000..dd24845 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_menu_help.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_menu_home.png b/app/src/main/res/drawable-mdpi/ic_menu_home.png new file mode 100644 index 0000000..f19f58d Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_menu_home.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_menu_info_details.png b/app/src/main/res/drawable-mdpi/ic_menu_info_details.png new file mode 100644 index 0000000..18b15b5 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_menu_info_details.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_menu_preferences.png b/app/src/main/res/drawable-mdpi/ic_menu_preferences.png new file mode 100644 index 0000000..ccc50e6 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_menu_preferences.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_menu_search.png b/app/src/main/res/drawable-mdpi/ic_menu_search.png new file mode 100644 index 0000000..d18f542 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_menu_search.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_action_event.png b/app/src/main/res/drawable-xhdpi/ic_action_event.png new file mode 100644 index 0000000..29fec10 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_action_event.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_action_sync.png b/app/src/main/res/drawable-xhdpi/ic_action_sync.png new file mode 100644 index 0000000..3defe6d Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_action_sync.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_action_sync_white.png b/app/src/main/res/drawable-xhdpi/ic_action_sync_white.png new file mode 100644 index 0000000..d8bdd7d Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_action_sync_white.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_menu_help.png b/app/src/main/res/drawable-xhdpi/ic_menu_help.png new file mode 100644 index 0000000..128c7e8 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_menu_help.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_menu_home.png b/app/src/main/res/drawable-xhdpi/ic_menu_home.png new file mode 100644 index 0000000..689f372 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_menu_home.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_menu_info_details.png b/app/src/main/res/drawable-xhdpi/ic_menu_info_details.png new file mode 100644 index 0000000..24ea543 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_menu_info_details.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_menu_preferences.png b/app/src/main/res/drawable-xhdpi/ic_menu_preferences.png new file mode 100644 index 0000000..02cfbad Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_menu_preferences.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_menu_search.png b/app/src/main/res/drawable-xhdpi/ic_menu_search.png new file mode 100644 index 0000000..4444495 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_menu_search.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_action_event.png b/app/src/main/res/drawable-xxhdpi/ic_action_event.png new file mode 100644 index 0000000..cbb790c Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_action_event.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_action_sync.png b/app/src/main/res/drawable-xxhdpi/ic_action_sync.png new file mode 100644 index 0000000..299c08a Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_action_sync.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_action_sync_white.png b/app/src/main/res/drawable-xxhdpi/ic_action_sync_white.png new file mode 100644 index 0000000..23158a2 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_action_sync_white.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_menu_help.png b/app/src/main/res/drawable-xxhdpi/ic_menu_help.png new file mode 100644 index 0000000..a16ad70 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_menu_help.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_menu_home.png b/app/src/main/res/drawable-xxhdpi/ic_menu_home.png new file mode 100644 index 0000000..23c67d0 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_menu_home.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_menu_info_details.png b/app/src/main/res/drawable-xxhdpi/ic_menu_info_details.png new file mode 100644 index 0000000..4414bea Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_menu_info_details.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_menu_preferences.png b/app/src/main/res/drawable-xxhdpi/ic_menu_preferences.png new file mode 100644 index 0000000..b039537 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_menu_preferences.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_menu_search.png b/app/src/main/res/drawable-xxhdpi/ic_menu_search.png new file mode 100644 index 0000000..22bb4c8 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_menu_search.png differ diff --git a/app/src/main/res/drawable/ic_info_outline_black.xml b/app/src/main/res/drawable/ic_info_outline_black.xml new file mode 100644 index 0000000..cf53e14 --- /dev/null +++ b/app/src/main/res/drawable/ic_info_outline_black.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/layout/about_layout.xml b/app/src/main/res/layout/about_layout.xml new file mode 100644 index 0000000..933256f --- /dev/null +++ b/app/src/main/res/layout/about_layout.xml @@ -0,0 +1,29 @@ + + + + + + + + + + diff --git a/app/src/main/res/layout/main_layout.xml b/app/src/main/res/layout/main_layout.xml new file mode 100644 index 0000000..bf1e191 --- /dev/null +++ b/app/src/main/res/layout/main_layout.xml @@ -0,0 +1,130 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/settings_layout.xml b/app/src/main/res/layout/settings_layout.xml new file mode 100644 index 0000000..8274687 --- /dev/null +++ b/app/src/main/res/layout/settings_layout.xml @@ -0,0 +1,7 @@ + + + + diff --git a/app/src/main/res/menu/menu_layout.xml b/app/src/main/res/menu/menu_layout.xml new file mode 100644 index 0000000..a9fa5d3 --- /dev/null +++ b/app/src/main/res/menu/menu_layout.xml @@ -0,0 +1,40 @@ + + + + + + + + + + + + diff --git a/app/src/main/res/mipmap-hdpi/ic_cow.png b/app/src/main/res/mipmap-hdpi/ic_cow.png new file mode 100644 index 0000000..2813dff Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_cow.png differ diff --git a/app/src/main/res/mipmap-mdpi/ic_cow.png b/app/src/main/res/mipmap-mdpi/ic_cow.png new file mode 100644 index 0000000..30d257f Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_cow.png differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_cow.png b/app/src/main/res/mipmap-xhdpi/ic_cow.png new file mode 100644 index 0000000..db8177d Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_cow.png differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_cow.png b/app/src/main/res/mipmap-xxhdpi/ic_cow.png new file mode 100644 index 0000000..50c33f3 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_cow.png differ diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml new file mode 100644 index 0000000..b8cb154 --- /dev/null +++ b/app/src/main/res/values-de/strings.xml @@ -0,0 +1,35 @@ + + + Copyright %1$d © Herbert Reiter + Version %1$s + Hilfe + Über + Erneut drücken um die App zu beenden. + Einstellungen + Startseite + Synchronisieren + Schließen + MoasdaWiki Termine + Datum: + Bitte erteile die Berechtigung %1$s damit es funktioniert + Kalender fertig aktualisiert + Vielen Dank für die Installation der MoasdaWiki App. Bitte konfiguriere nun die Serververbindung, um die Wikiseiten synchronisieren zu können. Drücke hier um die Einstellungen zu öffnen. + Die App hat noch keine Wikiseiten. Drücke hier um jetzt mit dem MoasdaWiki-Server zu synchronisieren. + Durchsuche Wiki + Suchen + Einstellungen + Mit Wikiserver synchronisieren + Hostname + Gib den Hostnamen des Rechners auf dem der MoasdaWiki server läuft an. + Host-Port + %1$d (Standardwert) + Status der Synchronisierung + + Nicht verbunden + Am Server angemeldet + Erfordert Berechtigung am Server + Keinen Wikiserver gefunden! + %1$d Dateien erfolgreich synchronisiert + Dateien sind bereits aktuell + Synchronisierung nicht möglich, bitte überprüfen Sie die Einstellungen und den Status! + diff --git a/app/src/main/res/values/dimens.xml b/app/src/main/res/values/dimens.xml new file mode 100644 index 0000000..5c1e384 --- /dev/null +++ b/app/src/main/res/values/dimens.xml @@ -0,0 +1,3 @@ + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..7442c59 --- /dev/null +++ b/app/src/main/res/values/strings.xml @@ -0,0 +1,36 @@ + + MoasdaWiki App + Copyright %1$d © Herbert Reiter + https://www.moasdawiki.net/ + Version %1$s + About + Press again to exit the app. + Help + Settings + Start page + Synchronize + Close + MoasdaWiki Events + Date + Please grant permission %1$s to make it work + Calendar update finished + Thank you for installing the MoasdaWiki App. Please configure the server connection to synchronize the Wiki pages. Tap here to go to settings. + The App has still no Wiki content. Tap here to synchronize with the MoasdaWiki server now. + Search in Wiki pages + Search + Settings + Synchronize with Wiki server + Host name + Enter the host name of a MoasdaWiki server instance. + Host port + %1$d (default value) + Synchronization status + + Not connected + Connected to server + Needs authorization at server + No Wiki server found! + Successfully synchronized %1$d files + All files are up-to-date + Synchronization failed, please check settings and status! + diff --git a/app/src/main/res/values/styles.xml b/app/src/main/res/values/styles.xml new file mode 100644 index 0000000..0f18e07 --- /dev/null +++ b/app/src/main/res/values/styles.xml @@ -0,0 +1,12 @@ + + + + + + + diff --git a/app/src/main/res/xml/authenticator.xml b/app/src/main/res/xml/authenticator.xml new file mode 100644 index 0000000..7edf0c0 --- /dev/null +++ b/app/src/main/res/xml/authenticator.xml @@ -0,0 +1,6 @@ + + diff --git a/app/src/main/res/xml/settings_fragment.xml b/app/src/main/res/xml/settings_fragment.xml new file mode 100644 index 0000000..b47f09c --- /dev/null +++ b/app/src/main/res/xml/settings_fragment.xml @@ -0,0 +1,42 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/xml/syncadapter.xml b/app/src/main/res/xml/syncadapter.xml new file mode 100644 index 0000000..b151c08 --- /dev/null +++ b/app/src/main/res/xml/syncadapter.xml @@ -0,0 +1,8 @@ + +