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