diff --git a/app/build.gradle b/app/build.gradle index 0f7557c..142c239 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -52,6 +52,7 @@ android { dependencies { coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:2.1.4' testImplementation 'junit:junit:4.13.2' + testImplementation 'org.json:json:20240303' implementation 'androidx.appcompat:appcompat:1.7.0' implementation 'androidx.preference:preference:1.2.1' implementation 'com.google.android.material:material:1.12.0' diff --git a/app/src/main/java/net/micode/notes/sync/webdav/WebDavClient.java b/app/src/main/java/net/micode/notes/sync/webdav/WebDavClient.java index ef7cced..9d8882d 100644 --- a/app/src/main/java/net/micode/notes/sync/webdav/WebDavClient.java +++ b/app/src/main/java/net/micode/notes/sync/webdav/WebDavClient.java @@ -31,7 +31,7 @@ import java.nio.charset.StandardCharsets; import java.util.Locale; -class WebDavClient { +class WebDavClient implements WebDavSyncManager.SnapshotTransport { static final int ERROR_INVALID_URL = 0; static final int ERROR_AUTH = 1; @@ -102,12 +102,14 @@ private String getSnapshot(boolean allowMissingSnapshot) throws IOException { } } - void putSnapshot(String snapshot) throws IOException { + @Override + public void putSnapshot(String snapshot) throws IOException { putJson(getSnapshotUrl(), snapshot); } - void putBackupSnapshot(String snapshot, long timestamp) throws IOException { - putJson(getBackupUrl(timestamp), snapshot); + @Override + public void putBackupSnapshot(String snapshot) throws IOException { + putJson(getBackupUrl(), snapshot); } private void putJson(URL url, String snapshot) throws IOException { @@ -163,8 +165,8 @@ private URL getSnapshotUrl() throws IOException { return resolveSnapshotUrl(mUrl); } - private URL getBackupUrl(long timestamp) throws IOException { - return resolveBackupUrl(mUrl, timestamp); + private URL getBackupUrl() throws IOException { + return resolveBackupUrl(mUrl); } static URL resolveSnapshotUrl(String url) throws IOException { @@ -175,9 +177,9 @@ static URL resolveSnapshotUrl(String url) throws IOException { return toUrl(rebuildUri(uri, appendPathSegment(uri.getRawPath(), SNAPSHOT_FILE_NAME))); } - static URL resolveBackupUrl(String url, long timestamp) throws IOException { + static URL resolveBackupUrl(String url) throws IOException { URI uri = parseWebDavUri(url); - String backupSuffix = ".backup-" + timestamp + ".json"; + String backupSuffix = ".backup.json"; if (isDirectJsonUrl(uri)) { String rawPath = getRawPath(uri); int slashIndex = rawPath.lastIndexOf('/'); diff --git a/app/src/main/java/net/micode/notes/sync/webdav/WebDavSyncManager.java b/app/src/main/java/net/micode/notes/sync/webdav/WebDavSyncManager.java index c5e8205..9520a9c 100644 --- a/app/src/main/java/net/micode/notes/sync/webdav/WebDavSyncManager.java +++ b/app/src/main/java/net/micode/notes/sync/webdav/WebDavSyncManager.java @@ -61,6 +61,8 @@ public class WebDavSyncManager { public static final int STATE_REMOTE_NOT_FOUND = 10; + public static final int STATE_BACKUP_ERROR = 11; + private static final String JSON_VERSION = "version"; private static final String JSON_GENERATED_AT = "generated_at"; @@ -165,6 +167,12 @@ public synchronized void cancelSync() { mCancelled = true; } + interface SnapshotTransport { + void putSnapshot(String snapshot) throws IOException; + + void putBackupSnapshot(String snapshot) throws IOException; + } + public int sync(Context context, WebDavSyncTask asyncTask) { synchronized (this) { if (mSyncing) { @@ -194,11 +202,7 @@ public int sync(Context context, WebDavSyncTask asyncTask) { return STATE_SYNC_CANCELLED; } - JSONObject remoteSnapshot = null; - if (!TextUtils.isEmpty(remotePayload)) { - remoteSnapshot = new JSONObject(remotePayload); - validateSnapshot(remoteSnapshot); - } + JSONObject remoteSnapshot = parseRemoteSnapshot(remotePayload); boolean localChanged = hasLocalChanges(context); long lastSyncTime = NotesPreferenceActivity.getLastSyncTime(context); @@ -218,14 +222,13 @@ public int sync(Context context, WebDavSyncTask asyncTask) { context.getString(R.string.sync_result_downloaded_remote)); } else { asyncTask.publishProgressMessage(context.getString(R.string.sync_progress_webdav_uploading)); - backupRemoteSnapshot(client, remotePayload); JSONObject localSnapshot = exportSnapshot(context); if (mCancelled) { NotesPreferenceActivity.setLastSyncResult(context, STATE_SYNC_CANCELLED, context.getString(R.string.sync_result_cancelled)); return STATE_SYNC_CANCELLED; } - client.putSnapshot(localSnapshot.toString()); + uploadSnapshotSafely(client, remotePayload, localSnapshot.toString()); cleanupTrash(context); resetLocalModified(context); int messageResId = remoteSnapshot != null && remoteGeneratedAt > lastSyncTime @@ -242,6 +245,11 @@ public int sync(Context context, WebDavSyncTask asyncTask) { } NotesPreferenceActivity.setLastSyncTime(context, System.currentTimeMillis()); return STATE_SUCCESS; + } catch (SnapshotBackupException e) { + Log.e(TAG, "WebDAV backup error", e); + NotesPreferenceActivity.setLastSyncResult(context, STATE_BACKUP_ERROR, + context.getString(R.string.sync_result_backup_error)); + return STATE_BACKUP_ERROR; } catch (WebDavClient.WebDavException e) { Log.e(TAG, "WebDAV protocol error", e); int state = mapWebDavError(e); @@ -324,6 +332,8 @@ public static int getResultMessageResId(int state) { return R.string.sync_result_invalid_url; case STATE_REMOTE_NOT_FOUND: return R.string.sync_result_remote_not_found; + case STATE_BACKUP_ERROR: + return R.string.sync_result_backup_error; default: return R.string.sync_result_internal_error; } @@ -345,17 +355,57 @@ private int mapWebDavError(WebDavClient.WebDavException e) { } } - private void backupRemoteSnapshot(WebDavClient client, String remotePayload) + private void backupLocalSnapshot(Context context, WebDavClient client) + throws IOException, JSONException { + backupSnapshotSafely(client, exportSnapshot(context, true).toString()); + } + + static JSONObject parseRemoteSnapshot(String remotePayload) throws JSONException { + if (isEmpty(remotePayload)) { + return null; + } + JSONObject snapshot = new JSONObject(remotePayload); + validateSnapshot(snapshot); + return snapshot; + } + + static void uploadSnapshotSafely(SnapshotTransport client, String previousSnapshot, + String newSnapshot) throws IOException { + backupSnapshotSafely(client, previousSnapshot); + try { + client.putSnapshot(newSnapshot); + } catch (IOException e) { + restoreRemoteSnapshot(client, previousSnapshot, e); + throw e; + } + } + + static void backupSnapshotSafely(SnapshotTransport client, String snapshot) throws IOException { - if (!TextUtils.isEmpty(remotePayload)) { - client.putBackupSnapshot(remotePayload, System.currentTimeMillis()); + if (isEmpty(snapshot)) { + return; + } + try { + client.putBackupSnapshot(snapshot); + } catch (IOException e) { + throw new SnapshotBackupException(e); } } - private void backupLocalSnapshot(Context context, WebDavClient client) - throws IOException, JSONException { - client.putBackupSnapshot(exportSnapshot(context, true).toString(), - System.currentTimeMillis()); + private static void restoreRemoteSnapshot(SnapshotTransport client, String previousSnapshot, + IOException uploadError) { + if (isEmpty(previousSnapshot)) { + return; + } + try { + client.putSnapshot(previousSnapshot); + } catch (IOException restoreError) { + uploadError.addSuppressed(restoreError); + } + } + + private static boolean isEmpty(String value) { + return value == null || value.length() == 0; } private boolean hasLocalChanges(Context context) { @@ -374,7 +424,7 @@ private boolean hasLocalChanges(Context context) { } } - private void validateSnapshot(JSONObject snapshot) throws JSONException { + private static void validateSnapshot(JSONObject snapshot) throws JSONException { if (snapshot.optInt(JSON_VERSION, -1) != 1 || !snapshot.has(JSON_NOTES) || !snapshot.has(JSON_DATA)) { throw new JSONException("Invalid WebDAV snapshot"); @@ -599,4 +649,10 @@ private ContentValues dataToValues(JSONObject data) { values.put(DataColumns.DATA5, data.optString(DataColumns.DATA5, "")); return values; } + + static class SnapshotBackupException extends IOException { + SnapshotBackupException(IOException cause) { + super(cause); + } + } } diff --git a/app/src/main/values-zh-rCN/strings.xml b/app/src/main/values-zh-rCN/strings.xml index 19ddea6..52d4700 100644 --- a/app/src/main/values-zh-rCN/strings.xml +++ b/app/src/main/values-zh-rCN/strings.xml @@ -110,6 +110,7 @@ WebDAV 同步失败:请检查用户名和密码 WebDAV 同步失败:请检查 WebDAV 路径和权限 未找到 WebDAV 快照。请同步一次以创建 mi-notes-sync.json,或检查路径。 + WebDAV 同步失败:无法创建备份,现有数据未被更改 设置 同步账号 diff --git a/app/src/main/values-zh-rTW/strings.xml b/app/src/main/values-zh-rTW/strings.xml index e12e142..49f9048 100644 --- a/app/src/main/values-zh-rTW/strings.xml +++ b/app/src/main/values-zh-rTW/strings.xml @@ -109,6 +109,7 @@ WebDAV 同步失敗:請檢查使用者名稱和密碼 WebDAV 同步失敗:請檢查 WebDAV 路徑和權限 未找到 WebDAV 快照。請同步一次以建立 mi-notes-sync.json,或檢查路徑。 + WebDAV 同步失敗:無法建立備份,現有資料未被變更 設置 同步賬號 diff --git a/app/src/main/values/strings.xml b/app/src/main/values/strings.xml index 7773293..6a03472 100644 --- a/app/src/main/values/strings.xml +++ b/app/src/main/values/strings.xml @@ -114,6 +114,7 @@ WebDAV sync failed: check username and password WebDAV sync failed: check WebDAV path and permissions WebDAV snapshot was not found. Sync once to create mi-notes-sync.json, or check the path. + WebDAV sync failed: backup could not be created, so existing data was not changed Settings Sync account diff --git a/app/src/test/java/net/micode/notes/sync/webdav/WebDavClientTest.java b/app/src/test/java/net/micode/notes/sync/webdav/WebDavClientTest.java index 640c69f..f3aec68 100644 --- a/app/src/test/java/net/micode/notes/sync/webdav/WebDavClientTest.java +++ b/app/src/test/java/net/micode/notes/sync/webdav/WebDavClientTest.java @@ -73,33 +73,32 @@ public void resolveSnapshotUrl_preservesAlreadyEncodedDirectFileName() throws Ex @Test public void resolveBackupUrl_usesDeterministicFolderBackupName() throws Exception { - URL url = WebDavClient.resolveBackupUrl("https://example.com/dav/笔记", 42); + URL url = WebDavClient.resolveBackupUrl("https://example.com/dav/笔记"); assertEquals("https://example.com/dav/%E7%AC%94%E8%AE%B0/" - + "mi-notes-sync.backup-42.json", + + "mi-notes-sync.backup.json", url.toExternalForm()); } @Test public void resolveBackupUrl_preservesChineseDirectFileBaseName() throws Exception { URL url = WebDavClient.resolveBackupUrl( - "https://example.com/dav/小米便签同步.json", 42); + "https://example.com/dav/小米便签同步.json"); assertEquals("https://example.com/dav/" + "%E5%B0%8F%E7%B1%B3%E4%BE%BF%E7%AD%BE%E5%90%8C%E6%AD%A5" - + ".backup-42.json", + + ".backup.json", url.toExternalForm()); } @Test public void resolveBackupUrl_preservesAlreadyEncodedDirectFileBaseName() throws Exception { URL url = WebDavClient.resolveBackupUrl("https://example.com/dav/" - + "%E5%B0%8F%E7%B1%B3%E4%BE%BF%E7%AD%BE%E5%90%8C%E6%AD%A5.json", - 42); + + "%E5%B0%8F%E7%B1%B3%E4%BE%BF%E7%AD%BE%E5%90%8C%E6%AD%A5.json"); assertEquals("https://example.com/dav/" + "%E5%B0%8F%E7%B1%B3%E4%BE%BF%E7%AD%BE%E5%90%8C%E6%AD%A5" - + ".backup-42.json", + + ".backup.json", url.toExternalForm()); assertFalse(url.toExternalForm().contains("%25E5")); } diff --git a/app/src/test/java/net/micode/notes/sync/webdav/WebDavSyncManagerTest.java b/app/src/test/java/net/micode/notes/sync/webdav/WebDavSyncManagerTest.java new file mode 100644 index 0000000..558ee22 --- /dev/null +++ b/app/src/test/java/net/micode/notes/sync/webdav/WebDavSyncManagerTest.java @@ -0,0 +1,143 @@ +/* + * Copyright (c) 2010-2011, The MiCode Open Source Community (www.micode.net) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package net.micode.notes.sync.webdav; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; + +import org.json.JSONException; +import org.junit.Test; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; + +public class WebDavSyncManagerTest { + private static final String VALID_REMOTE = + "{\"version\":1,\"generated_at\":1,\"notes\":[],\"data\":[]}"; + + @Test + public void uploadSnapshotSafely_backsUpPreviousSnapshotBeforeUpload() throws Exception { + FakeTransport transport = new FakeTransport(); + + WebDavSyncManager.uploadSnapshotSafely(transport, "remote", "local"); + + assertEquals("remote", transport.backupSnapshot); + assertEquals("local", transport.remoteSnapshot); + assertEquals("backup:remote", transport.calls.get(0)); + assertEquals("put:local", transport.calls.get(1)); + } + + @Test + public void uploadSnapshotSafely_backupFailureDoesNotUpload() { + FakeTransport transport = new FakeTransport(); + transport.failBackup = true; + + try { + WebDavSyncManager.uploadSnapshotSafely(transport, "remote", "local"); + fail("Expected backup failure"); + } catch (IOException e) { + assertTrue(e instanceof WebDavSyncManager.SnapshotBackupException); + } + + assertNull(transport.backupSnapshot); + assertEquals("remote", transport.remoteSnapshot); + assertEquals(1, transport.calls.size()); + assertEquals("backup:remote", transport.calls.get(0)); + } + + @Test + public void uploadSnapshotSafely_uploadFailureRestoresPreviousSnapshot() { + FakeTransport transport = new FakeTransport(); + transport.failNextUpload = true; + + try { + WebDavSyncManager.uploadSnapshotSafely(transport, "remote", "local"); + fail("Expected upload failure"); + } catch (IOException e) { + assertEquals("upload failed", e.getMessage()); + } + + assertEquals("remote", transport.backupSnapshot); + assertEquals("remote", transport.remoteSnapshot); + assertEquals("backup:remote", transport.calls.get(0)); + assertEquals("put:local", transport.calls.get(1)); + assertEquals("put:remote", transport.calls.get(2)); + } + + @Test + public void parseRemoteSnapshot_acceptsMissingRemoteSnapshot() throws Exception { + assertNull(WebDavSyncManager.parseRemoteSnapshot(null)); + assertNull(WebDavSyncManager.parseRemoteSnapshot("")); + } + + @Test + public void parseRemoteSnapshot_acceptsValidSnapshot() throws Exception { + assertEquals(1, WebDavSyncManager.parseRemoteSnapshot(VALID_REMOTE) + .getInt("version")); + } + + @Test + public void parseRemoteSnapshot_rejectsCorruptedRemoteSnapshot() { + try { + WebDavSyncManager.parseRemoteSnapshot("{\"version\":1,\"notes\":[]}"); + fail("Expected invalid snapshot"); + } catch (JSONException expected) { + assertTrue(expected.getMessage().contains("Invalid WebDAV snapshot")); + } + + try { + WebDavSyncManager.parseRemoteSnapshot("{not-json"); + fail("Expected JSON parser failure"); + } catch (JSONException expected) { + assertTrue(expected.getMessage().length() > 0); + } + } + + private static class FakeTransport implements WebDavSyncManager.SnapshotTransport { + final List calls = new ArrayList(); + + String backupSnapshot; + + String remoteSnapshot = "remote"; + + boolean failBackup; + + boolean failNextUpload; + + @Override + public void putSnapshot(String snapshot) throws IOException { + calls.add("put:" + snapshot); + remoteSnapshot = snapshot; + if (failNextUpload) { + failNextUpload = false; + throw new IOException("upload failed"); + } + } + + @Override + public void putBackupSnapshot(String snapshot) throws IOException { + calls.add("backup:" + snapshot); + if (failBackup) { + throw new IOException("backup failed"); + } + backupSnapshot = snapshot; + } + } +} diff --git a/res/values-zh-rCN/strings.xml b/res/values-zh-rCN/strings.xml index 19ddea6..52d4700 100644 --- a/res/values-zh-rCN/strings.xml +++ b/res/values-zh-rCN/strings.xml @@ -110,6 +110,7 @@ WebDAV 同步失败:请检查用户名和密码 WebDAV 同步失败:请检查 WebDAV 路径和权限 未找到 WebDAV 快照。请同步一次以创建 mi-notes-sync.json,或检查路径。 + WebDAV 同步失败:无法创建备份,现有数据未被更改 设置 同步账号 diff --git a/res/values-zh-rTW/strings.xml b/res/values-zh-rTW/strings.xml index e12e142..49f9048 100644 --- a/res/values-zh-rTW/strings.xml +++ b/res/values-zh-rTW/strings.xml @@ -109,6 +109,7 @@ WebDAV 同步失敗:請檢查使用者名稱和密碼 WebDAV 同步失敗:請檢查 WebDAV 路徑和權限 未找到 WebDAV 快照。請同步一次以建立 mi-notes-sync.json,或檢查路徑。 + WebDAV 同步失敗:無法建立備份,現有資料未被變更 設置 同步賬號 diff --git a/res/values/strings.xml b/res/values/strings.xml index 7773293..6a03472 100644 --- a/res/values/strings.xml +++ b/res/values/strings.xml @@ -114,6 +114,7 @@ WebDAV sync failed: check username and password WebDAV sync failed: check WebDAV path and permissions WebDAV snapshot was not found. Sync once to create mi-notes-sync.json, or check the path. + WebDAV sync failed: backup could not be created, so existing data was not changed Settings Sync account