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