diff --git a/app/build.gradle b/app/build.gradle
index 5334450..0f7557c 100644
--- a/app/build.gradle
+++ b/app/build.gradle
@@ -51,6 +51,7 @@ android {
dependencies {
coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:2.1.4'
+ testImplementation 'junit:junit:4.13.2'
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 78f925f..ef7cced 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
@@ -25,6 +25,8 @@
import java.io.IOException;
import java.net.HttpURLConnection;
import java.net.MalformedURLException;
+import java.net.URI;
+import java.net.URISyntaxException;
import java.net.URL;
import java.nio.charset.StandardCharsets;
import java.util.Locale;
@@ -158,42 +160,155 @@ private HttpURLConnection openConnection(String method, URL url) throws IOExcept
}
private URL getSnapshotUrl() throws IOException {
- String trimmedUrl = mUrl == null ? "" : mUrl.trim();
+ return resolveSnapshotUrl(mUrl);
+ }
+
+ private URL getBackupUrl(long timestamp) throws IOException {
+ return resolveBackupUrl(mUrl, timestamp);
+ }
+
+ static URL resolveSnapshotUrl(String url) throws IOException {
+ URI uri = parseWebDavUri(url);
+ if (isDirectJsonUrl(uri)) {
+ return toUrl(uri);
+ }
+ return toUrl(rebuildUri(uri, appendPathSegment(uri.getRawPath(), SNAPSHOT_FILE_NAME)));
+ }
+
+ static URL resolveBackupUrl(String url, long timestamp) throws IOException {
+ URI uri = parseWebDavUri(url);
+ String backupSuffix = ".backup-" + timestamp + ".json";
+ if (isDirectJsonUrl(uri)) {
+ String rawPath = getRawPath(uri);
+ int slashIndex = rawPath.lastIndexOf('/');
+ String parent = slashIndex >= 0 ? rawPath.substring(0, slashIndex + 1) : "/";
+ String fileName = slashIndex >= 0 ? rawPath.substring(slashIndex + 1) : rawPath;
+ String backupFileName = fileName.substring(0, fileName.length() - 5) + backupSuffix;
+ return toUrl(rebuildUri(uri, parent + backupFileName));
+ }
+ return toUrl(rebuildUri(uri, appendPathSegment(uri.getRawPath(),
+ SNAPSHOT_FILE_NAME.substring(0, SNAPSHOT_FILE_NAME.length() - 5)
+ + backupSuffix)));
+ }
+
+ static boolean hrefMatchesUrl(String href, URL expectedUrl) throws IOException {
+ return normalizeHrefPath(href).equals(normalizeHrefPath(expectedUrl.toExternalForm()));
+ }
+
+ static String normalizeHrefPath(String href) throws IOException {
try {
- if (trimmedUrl.toLowerCase(Locale.US).endsWith(".json")) {
- return new URL(trimmedUrl);
+ URI uri = new URI(href == null ? "" : href.trim()).normalize();
+ String rawPath = uri.getRawPath();
+ return decodePercentEncoded(rawPath == null ? "" : rawPath);
+ } catch (URISyntaxException e) {
+ throw new WebDavException(ERROR_INVALID_URL, "Invalid WebDAV href");
+ }
+ }
+
+ private static URI parseWebDavUri(String url) throws IOException {
+ String trimmedUrl = url == null ? "" : url.trim();
+ try {
+ URI uri = new URI(trimmedUrl);
+ if (uri.getScheme() == null || uri.getRawAuthority() == null) {
+ throw new WebDavException(ERROR_INVALID_URL, "Invalid WebDAV URL");
}
- if (!trimmedUrl.endsWith("/")) {
- trimmedUrl += "/";
+ if (!"http".equalsIgnoreCase(uri.getScheme())
+ && !"https".equalsIgnoreCase(uri.getScheme())) {
+ throw new WebDavException(ERROR_INVALID_URL,
+ "Unsupported WebDAV URL scheme: " + uri.getScheme());
}
- return new URL(trimmedUrl + SNAPSHOT_FILE_NAME);
- } catch (MalformedURLException e) {
+ return uri;
+ } catch (URISyntaxException e) {
throw new WebDavException(ERROR_INVALID_URL, "Invalid WebDAV URL");
}
}
- private URL getBackupUrl(long timestamp) throws IOException {
- String trimmedUrl = mUrl == null ? "" : mUrl.trim();
- String backupSuffix = ".backup-" + timestamp + ".json";
+ private static URL toUrl(URI uri) throws IOException {
try {
- if (trimmedUrl.toLowerCase(Locale.US).endsWith(".json")) {
- int slashIndex = trimmedUrl.lastIndexOf('/');
- String parent = slashIndex >= 0 ? trimmedUrl.substring(0, slashIndex + 1) : "";
- String fileName = slashIndex >= 0 ? trimmedUrl.substring(slashIndex + 1)
- : trimmedUrl;
- return new URL(parent + fileName.substring(0, fileName.length() - 5)
- + backupSuffix);
- }
- if (!trimmedUrl.endsWith("/")) {
- trimmedUrl += "/";
- }
- return new URL(trimmedUrl + SNAPSHOT_FILE_NAME.substring(0,
- SNAPSHOT_FILE_NAME.length() - 5) + backupSuffix);
+ return new URL(uri.toASCIIString());
} catch (MalformedURLException e) {
throw new WebDavException(ERROR_INVALID_URL, "Invalid WebDAV URL");
}
}
+ private static boolean isDirectJsonUrl(URI uri) {
+ return getRawPath(uri).toLowerCase(Locale.US).endsWith(".json");
+ }
+
+ private static String appendPathSegment(String rawPath, String segment) {
+ String basePath = rawPath == null || rawPath.length() == 0 ? "/" : rawPath;
+ if (!basePath.endsWith("/")) {
+ basePath += "/";
+ }
+ return basePath + segment;
+ }
+
+ private static String getRawPath(URI uri) {
+ String rawPath = uri.getRawPath();
+ return rawPath == null || rawPath.length() == 0 ? "/" : rawPath;
+ }
+
+ private static URI rebuildUri(URI baseUri, String rawPath) throws IOException {
+ StringBuilder builder = new StringBuilder();
+ builder.append(baseUri.getScheme()).append(":");
+ if (baseUri.getRawAuthority() != null) {
+ builder.append("//").append(baseUri.getRawAuthority());
+ }
+ builder.append(rawPath == null || rawPath.length() == 0 ? "/" : rawPath);
+ if (baseUri.getRawQuery() != null) {
+ builder.append("?").append(baseUri.getRawQuery());
+ }
+ if (baseUri.getRawFragment() != null) {
+ builder.append("#").append(baseUri.getRawFragment());
+ }
+ try {
+ return new URI(builder.toString());
+ } catch (URISyntaxException e) {
+ throw new WebDavException(ERROR_INVALID_URL, "Invalid WebDAV URL");
+ }
+ }
+
+ private static String decodePercentEncoded(String rawPath) {
+ StringBuilder decoded = new StringBuilder();
+ ByteArrayOutputStream bytes = new ByteArrayOutputStream();
+ for (int i = 0; i < rawPath.length(); i++) {
+ char current = rawPath.charAt(i);
+ if (current == '%' && i + 2 < rawPath.length()) {
+ int high = hexToInt(rawPath.charAt(i + 1));
+ int low = hexToInt(rawPath.charAt(i + 2));
+ if (high >= 0 && low >= 0) {
+ bytes.write((high << 4) + low);
+ i += 2;
+ continue;
+ }
+ }
+ appendDecodedBytes(decoded, bytes);
+ decoded.append(current);
+ }
+ appendDecodedBytes(decoded, bytes);
+ return decoded.toString();
+ }
+
+ private static void appendDecodedBytes(StringBuilder decoded, ByteArrayOutputStream bytes) {
+ if (bytes.size() > 0) {
+ decoded.append(new String(bytes.toByteArray(), StandardCharsets.UTF_8));
+ bytes.reset();
+ }
+ }
+
+ private static int hexToInt(char value) {
+ if (value >= '0' && value <= '9') {
+ return value - '0';
+ }
+ if (value >= 'a' && value <= 'f') {
+ return value - 'a' + 10;
+ }
+ if (value >= 'A' && value <= 'F') {
+ return value - 'A' + 10;
+ }
+ return -1;
+ }
+
private void throwForResponse(String method, int responseCode) throws IOException {
if (responseCode == HttpURLConnection.HTTP_UNAUTHORIZED) {
throw new WebDavException(ERROR_AUTH, method + " failed: " + responseCode);
diff --git a/app/src/main/java/net/micode/notes/ui/NotesListActivity.java b/app/src/main/java/net/micode/notes/ui/NotesListActivity.java
index a28c012..be459c1 100644
--- a/app/src/main/java/net/micode/notes/ui/NotesListActivity.java
+++ b/app/src/main/java/net/micode/notes/ui/NotesListActivity.java
@@ -55,6 +55,7 @@
import net.micode.notes.R;
import net.micode.notes.data.Notes;
import net.micode.notes.data.Notes.NoteColumns;
+import net.micode.notes.data.entity.NoteEntity;
import net.micode.notes.model.WorkingNote;
import net.micode.notes.sync.webdav.WebDavSyncService;
import net.micode.notes.tool.BackupUtils;
diff --git a/app/src/main/values-zh-rCN/strings.xml b/app/src/main/values-zh-rCN/strings.xml
index e50b79f..19ddea6 100644
--- a/app/src/main/values-zh-rCN/strings.xml
+++ b/app/src/main/values-zh-rCN/strings.xml
@@ -129,7 +129,7 @@
成功
失败
%3$s\n%1$s\n%2$s
- 使用 WebDAV 文件夹 URL 时会保存 mi-notes-sync.json;也可以填写以 mi-notes-sync.json 结尾的完整文件 URL。冲突规则:远端快照较新且本机没有本地修改时下载;否则上传本机便签。本地导入或覆盖远端前会写入带时间戳的备份。
+ 使用 WebDAV 文件夹 URL 时会保存 mi-notes-sync.json;也可以填写完整的 .json 文件 URL,支持“小米便签同步.json”这类中文文件名。冲突规则:远端快照较新且本机没有本地修改时下载;否则上传本机便签。本地导入或覆盖远端前会写入带时间戳的备份。
上次同步于 %1$s
yyyy-MM-dd HH:mm:ss
上次同步:尚未同步
diff --git a/app/src/main/values-zh-rTW/strings.xml b/app/src/main/values-zh-rTW/strings.xml
index 71f23f9..e12e142 100644
--- a/app/src/main/values-zh-rTW/strings.xml
+++ b/app/src/main/values-zh-rTW/strings.xml
@@ -128,7 +128,7 @@
成功
失敗
%3$s\n%1$s\n%2$s
- 使用 WebDAV 資料夾 URL 時會儲存 mi-notes-sync.json;也可以填寫以 mi-notes-sync.json 結尾的完整檔案 URL。衝突規則:遠端快照較新且本機沒有本地修改時下載;否則上傳本機便籤。本地匯入或覆蓋遠端前會寫入帶時間戳的備份。
+ 使用 WebDAV 資料夾 URL 時會儲存 mi-notes-sync.json;也可以填寫完整的 .json 檔案 URL,支援「小米便簽同步.json」這類中文檔名。衝突規則:遠端快照較新且本機沒有本地修改時下載;否則上傳本機便籤。本地匯入或覆蓋遠端前會寫入帶時間戳的備份。
上次同步于 %1$s
yyyy-MM-dd HH:mm:ss
上次同步:尚未同步
diff --git a/app/src/main/values/strings.xml b/app/src/main/values/strings.xml
index 56f1cf8..7773293 100644
--- a/app/src/main/values/strings.xml
+++ b/app/src/main/values/strings.xml
@@ -133,7 +133,7 @@
Success
Failed
%3$s\n%1$s\n%2$s
- Use a WebDAV folder URL to store mi-notes-sync.json, or enter a direct URL ending with mi-notes-sync.json. Conflict rule: if the remote snapshot is newer and this device has no local changes, it downloads; otherwise this device uploads local notes. A timestamped backup is written before local import or remote overwrite.
+ Use a WebDAV folder URL to store mi-notes-sync.json, or enter a direct .json file URL, including Chinese filenames such as 小米便签同步.json. Conflict rule: if the remote snapshot is newer and this device has no local changes, it downloads; otherwise this device uploads local notes. A timestamped backup is written before local import or remote overwrite.
Last sync time %1$s
Last sync time: never synced
yyyy-MM-dd hh:mm:ss
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
new file mode 100644
index 0000000..640c69f
--- /dev/null
+++ b/app/src/test/java/net/micode/notes/sync/webdav/WebDavClientTest.java
@@ -0,0 +1,130 @@
+/*
+ * 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.assertFalse;
+import static org.junit.Assert.assertTrue;
+
+import org.junit.Test;
+
+import java.net.URL;
+
+public class WebDavClientTest {
+ @Test
+ public void resolveSnapshotUrl_encodesChineseFolderPath() throws Exception {
+ URL url = WebDavClient.resolveSnapshotUrl("https://example.com/dav/笔记/");
+
+ assertEquals("https://example.com/dav/%E7%AC%94%E8%AE%B0/mi-notes-sync.json",
+ url.toExternalForm());
+ }
+
+ @Test
+ public void resolveSnapshotUrl_joinsFolderWithoutTrailingSlash() throws Exception {
+ URL url = WebDavClient.resolveSnapshotUrl("https://example.com/dav/笔记");
+
+ assertEquals("https://example.com/dav/%E7%AC%94%E8%AE%B0/mi-notes-sync.json",
+ url.toExternalForm());
+ }
+
+ @Test
+ public void resolveSnapshotUrl_preservesAlreadyEncodedFolderPath() throws Exception {
+ URL url = WebDavClient.resolveSnapshotUrl(
+ "https://example.com/dav/%E7%AC%94%E8%AE%B0");
+
+ assertEquals("https://example.com/dav/%E7%AC%94%E8%AE%B0/mi-notes-sync.json",
+ url.toExternalForm());
+ assertFalse(url.toExternalForm().contains("%25E7"));
+ }
+
+ @Test
+ public void resolveSnapshotUrl_supportsChineseDirectFileName() throws Exception {
+ URL url = WebDavClient.resolveSnapshotUrl("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.json",
+ url.toExternalForm());
+ }
+
+ @Test
+ public void resolveSnapshotUrl_preservesAlreadyEncodedDirectFileName() throws Exception {
+ String encodedUrl = "https://example.com/dav/"
+ + "%E5%B0%8F%E7%B1%B3%E4%BE%BF%E7%AD%BE%E5%90%8C%E6%AD%A5.json";
+
+ URL url = WebDavClient.resolveSnapshotUrl(encodedUrl);
+
+ assertEquals(encodedUrl, url.toExternalForm());
+ assertFalse(url.toExternalForm().contains("%25E5"));
+ }
+
+ @Test
+ public void resolveBackupUrl_usesDeterministicFolderBackupName() throws Exception {
+ URL url = WebDavClient.resolveBackupUrl("https://example.com/dav/笔记", 42);
+
+ assertEquals("https://example.com/dav/%E7%AC%94%E8%AE%B0/"
+ + "mi-notes-sync.backup-42.json",
+ url.toExternalForm());
+ }
+
+ @Test
+ public void resolveBackupUrl_preservesChineseDirectFileBaseName() throws Exception {
+ URL url = WebDavClient.resolveBackupUrl(
+ "https://example.com/dav/小米便签同步.json", 42);
+
+ 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",
+ 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);
+
+ 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",
+ url.toExternalForm());
+ assertFalse(url.toExternalForm().contains("%25E5"));
+ }
+
+ @Test
+ public void normalizeHrefPath_decodesPercentEncodedChinesePath() throws Exception {
+ String path = WebDavClient.normalizeHrefPath(
+ "/dav/%E7%AC%94%E8%AE%B0/"
+ + "%E5%B0%8F%E7%B1%B3%E4%BE%BF%E7%AD%BE%E5%90%8C%E6%AD%A5.json");
+
+ assertEquals("/dav/笔记/小米便签同步.json", path);
+ }
+
+ @Test
+ public void hrefMatchesUrl_comparesEncodedHrefWithChineseUrl() throws Exception {
+ URL expectedUrl = WebDavClient.resolveSnapshotUrl(
+ "https://example.com/dav/笔记/小米便签同步.json");
+
+ assertTrue(WebDavClient.hrefMatchesUrl(
+ "/dav/%E7%AC%94%E8%AE%B0/"
+ + "%E5%B0%8F%E7%B1%B3%E4%BE%BF%E7%AD%BE%E5%90%8C%E6%AD%A5.json",
+ expectedUrl));
+ assertTrue(WebDavClient.hrefMatchesUrl(
+ "https://example.com/dav/%E7%AC%94%E8%AE%B0/"
+ + "%E5%B0%8F%E7%B1%B3%E4%BE%BF%E7%AD%BE%E5%90%8C%E6%AD%A5.json",
+ expectedUrl));
+ }
+}
diff --git a/res/values-zh-rCN/strings.xml b/res/values-zh-rCN/strings.xml
index e50b79f..19ddea6 100644
--- a/res/values-zh-rCN/strings.xml
+++ b/res/values-zh-rCN/strings.xml
@@ -129,7 +129,7 @@
成功
失败
%3$s\n%1$s\n%2$s
- 使用 WebDAV 文件夹 URL 时会保存 mi-notes-sync.json;也可以填写以 mi-notes-sync.json 结尾的完整文件 URL。冲突规则:远端快照较新且本机没有本地修改时下载;否则上传本机便签。本地导入或覆盖远端前会写入带时间戳的备份。
+ 使用 WebDAV 文件夹 URL 时会保存 mi-notes-sync.json;也可以填写完整的 .json 文件 URL,支持“小米便签同步.json”这类中文文件名。冲突规则:远端快照较新且本机没有本地修改时下载;否则上传本机便签。本地导入或覆盖远端前会写入带时间戳的备份。
上次同步于 %1$s
yyyy-MM-dd HH:mm:ss
上次同步:尚未同步
diff --git a/res/values-zh-rTW/strings.xml b/res/values-zh-rTW/strings.xml
index 71f23f9..e12e142 100644
--- a/res/values-zh-rTW/strings.xml
+++ b/res/values-zh-rTW/strings.xml
@@ -128,7 +128,7 @@
成功
失敗
%3$s\n%1$s\n%2$s
- 使用 WebDAV 資料夾 URL 時會儲存 mi-notes-sync.json;也可以填寫以 mi-notes-sync.json 結尾的完整檔案 URL。衝突規則:遠端快照較新且本機沒有本地修改時下載;否則上傳本機便籤。本地匯入或覆蓋遠端前會寫入帶時間戳的備份。
+ 使用 WebDAV 資料夾 URL 時會儲存 mi-notes-sync.json;也可以填寫完整的 .json 檔案 URL,支援「小米便簽同步.json」這類中文檔名。衝突規則:遠端快照較新且本機沒有本地修改時下載;否則上傳本機便籤。本地匯入或覆蓋遠端前會寫入帶時間戳的備份。
上次同步于 %1$s
yyyy-MM-dd HH:mm:ss
上次同步:尚未同步
diff --git a/res/values/strings.xml b/res/values/strings.xml
index 56f1cf8..7773293 100644
--- a/res/values/strings.xml
+++ b/res/values/strings.xml
@@ -133,7 +133,7 @@
Success
Failed
%3$s\n%1$s\n%2$s
- Use a WebDAV folder URL to store mi-notes-sync.json, or enter a direct URL ending with mi-notes-sync.json. Conflict rule: if the remote snapshot is newer and this device has no local changes, it downloads; otherwise this device uploads local notes. A timestamped backup is written before local import or remote overwrite.
+ Use a WebDAV folder URL to store mi-notes-sync.json, or enter a direct .json file URL, including Chinese filenames such as 小米便签同步.json. Conflict rule: if the remote snapshot is newer and this device has no local changes, it downloads; otherwise this device uploads local notes. A timestamped backup is written before local import or remote overwrite.
Last sync time %1$s
Last sync time: never synced
yyyy-MM-dd hh:mm:ss