465 lines
19 KiB
Java
465 lines
19 KiB
Java
/*
|
|
* MoasdaWiki App
|
|
* Copyright (C) 2008 - 2021 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 version 3 as published
|
|
* by the Free Software Foundation (GPL-3.0-only).
|
|
*
|
|
* 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 <https://www.gnu.org/licenses/gpl-3.0.html>.
|
|
*/
|
|
|
|
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.service.repository.AnyFile;
|
|
import net.moasdawiki.service.repository.RepositoryService;
|
|
import net.moasdawiki.service.sync.*;
|
|
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..
|
|
*/
|
|
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;
|
|
|
|
public SynchronizeWikiClient(@NotNull Context mContext, @NotNull Logger logger, @NotNull Settings settings,
|
|
@NotNull RepositoryService repositoryService) {
|
|
this.mContext = mContext;
|
|
this.logger = logger;
|
|
this.settings = settings;
|
|
this.repositoryService = repositoryService;
|
|
this.random = new SecureRandom();
|
|
}
|
|
|
|
/**
|
|
* Verbindet sich mit dem Wikiserver. Ist bereits eine Serversession vorhanden, wird diese
|
|
* weiter verwendet.
|
|
*/
|
|
public boolean createAndCheckSession() {
|
|
String serverHostPort = getServerHostPort();
|
|
if (serverHostPort == null) {
|
|
return false;
|
|
}
|
|
|
|
PreferenceManager.getDefaultSharedPreferences(mContext);
|
|
SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(mContext);
|
|
String serverSessionId = preferences.getString(Constants.PREFERENCES_SYNC_SERVER_SESSION_ID, null);
|
|
|
|
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 {
|
|
boolean sessionValid = createAndCheckSession();
|
|
if (!sessionValid) {
|
|
throw new ServiceException("No valid server session found");
|
|
}
|
|
|
|
String serverHostPort = getServerHostPort();
|
|
if (serverHostPort == null) {
|
|
throw new ServiceException("No wiki server configured");
|
|
}
|
|
|
|
SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(mContext);
|
|
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);
|
|
}
|
|
|
|
// 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; 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
|
|
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");
|
|
}
|
|
|
|
@Nullable
|
|
private String getServerHostPort() {
|
|
PreferenceManager.getDefaultSharedPreferences(mContext);
|
|
SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(mContext);
|
|
|
|
String host = preferences.getString(Constants.PREFERENCES_SYNC_SERVER_HOST, null);
|
|
if (host == null) {
|
|
return null;
|
|
}
|
|
|
|
String port = preferences.getString(Constants.PREFERENCES_SYNC_SERVER_PORT, null);
|
|
if (port == null || port.trim().isEmpty()) {
|
|
port = Integer.toString(settings.getServerPort());
|
|
}
|
|
|
|
return host + ':' + port;
|
|
}
|
|
|
|
/**
|
|
* Versucht den Wifi-Hostnamen zu ermitteln und gibt ihn zurück.
|
|
*/
|
|
@Nullable
|
|
private String getLocalHostname() {
|
|
try {
|
|
String wlanHostname = null;
|
|
String ethHostname = null;
|
|
Enumeration<NetworkInterface> networkInterfaces = NetworkInterface.getNetworkInterfaces();
|
|
while (networkInterfaces.hasMoreElements()) {
|
|
NetworkInterface ni = networkInterfaces.nextElement();
|
|
Enumeration<InetAddress> 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 extends AbstractSyncXml> T parseXml(@NotNull String xml, @NotNull Class<T> 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.
|
|
*/
|
|
@FunctionalInterface
|
|
public interface ProgressFeedback {
|
|
void progress(int step, int total);
|
|
}
|
|
}
|