Files
moasdawiki-app/app/src/main/java/net/moasdawiki/app/SynchronizeWikiClient.java
T
2021-05-13 10:31:18 +02:00

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);
}
}