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 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ generateDebugSources
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ 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 @@
+
+