Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions app/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
161 changes: 138 additions & 23 deletions app/src/main/java/net/micode/notes/sync/webdav/WebDavClient.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
2 changes: 1 addition & 1 deletion app/src/main/values-zh-rCN/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -129,7 +129,7 @@
<string name="preferences_webdav_last_result_success">成功</string>
<string name="preferences_webdav_last_result_failed">失败</string>
<string name="preferences_webdav_last_result_format">%3$s\n%1$s\n%2$s</string>
<string name="preferences_webdav_help">使用 WebDAV 文件夹 URL 时会保存 mi-notes-sync.json;也可以填写以 mi-notes-sync.json 结尾的完整文件 URL。冲突规则:远端快照较新且本机没有本地修改时下载;否则上传本机便签。本地导入或覆盖远端前会写入带时间戳的备份。</string>
<string name="preferences_webdav_help">使用 WebDAV 文件夹 URL 时会保存 mi-notes-sync.json;也可以填写完整的 .json 文件 URL,支持“小米便签同步.json”这类中文文件名。冲突规则:远端快照较新且本机没有本地修改时下载;否则上传本机便签。本地导入或覆盖远端前会写入带时间戳的备份。</string>
<string name="preferences_last_sync_time">上次同步于 %1$s</string>
<string name="preferences_last_sync_time_format">yyyy-MM-dd HH:mm:ss</string>
<string name="preferences_last_sync_time_never">上次同步:尚未同步</string>
Expand Down
2 changes: 1 addition & 1 deletion app/src/main/values-zh-rTW/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -128,7 +128,7 @@
<string name="preferences_webdav_last_result_success">成功</string>
<string name="preferences_webdav_last_result_failed">失敗</string>
<string name="preferences_webdav_last_result_format">%3$s\n%1$s\n%2$s</string>
<string name="preferences_webdav_help">使用 WebDAV 資料夾 URL 時會儲存 mi-notes-sync.json;也可以填寫以 mi-notes-sync.json 結尾的完整檔案 URL。衝突規則:遠端快照較新且本機沒有本地修改時下載;否則上傳本機便籤。本地匯入或覆蓋遠端前會寫入帶時間戳的備份。</string>
<string name="preferences_webdav_help">使用 WebDAV 資料夾 URL 時會儲存 mi-notes-sync.json;也可以填寫完整的 .json 檔案 URL,支援「小米便簽同步.json」這類中文檔名。衝突規則:遠端快照較新且本機沒有本地修改時下載;否則上傳本機便籤。本地匯入或覆蓋遠端前會寫入帶時間戳的備份。</string>
<string name="preferences_last_sync_time">上次同步于 %1$s</string>
<string name="preferences_last_sync_time_format">yyyy-MM-dd HH:mm:ss</string>
<string name="preferences_last_sync_time_never">上次同步:尚未同步</string>
Expand Down
2 changes: 1 addition & 1 deletion app/src/main/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -133,7 +133,7 @@
<string name="preferences_webdav_last_result_success">Success</string>
<string name="preferences_webdav_last_result_failed">Failed</string>
<string name="preferences_webdav_last_result_format">%3$s\n%1$s\n%2$s</string>
<string name="preferences_webdav_help">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.</string>
<string name="preferences_webdav_help">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.</string>
<string name="preferences_last_sync_time">Last sync time %1$s</string>
<string name="preferences_last_sync_time_never">Last sync time: never synced</string>
<string name="preferences_last_sync_time_format">yyyy-MM-dd hh:mm:ss</string>
Expand Down
130 changes: 130 additions & 0 deletions app/src/test/java/net/micode/notes/sync/webdav/WebDavClientTest.java
Original file line number Diff line number Diff line change
@@ -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));
}
}
2 changes: 1 addition & 1 deletion res/values-zh-rCN/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -129,7 +129,7 @@
<string name="preferences_webdav_last_result_success">成功</string>
<string name="preferences_webdav_last_result_failed">失败</string>
<string name="preferences_webdav_last_result_format">%3$s\n%1$s\n%2$s</string>
<string name="preferences_webdav_help">使用 WebDAV 文件夹 URL 时会保存 mi-notes-sync.json;也可以填写以 mi-notes-sync.json 结尾的完整文件 URL。冲突规则:远端快照较新且本机没有本地修改时下载;否则上传本机便签。本地导入或覆盖远端前会写入带时间戳的备份。</string>
<string name="preferences_webdav_help">使用 WebDAV 文件夹 URL 时会保存 mi-notes-sync.json;也可以填写完整的 .json 文件 URL,支持“小米便签同步.json”这类中文文件名。冲突规则:远端快照较新且本机没有本地修改时下载;否则上传本机便签。本地导入或覆盖远端前会写入带时间戳的备份。</string>
<string name="preferences_last_sync_time">上次同步于 %1$s</string>
<string name="preferences_last_sync_time_format">yyyy-MM-dd HH:mm:ss</string>
<string name="preferences_last_sync_time_never">上次同步:尚未同步</string>
Expand Down
2 changes: 1 addition & 1 deletion res/values-zh-rTW/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -128,7 +128,7 @@
<string name="preferences_webdav_last_result_success">成功</string>
<string name="preferences_webdav_last_result_failed">失敗</string>
<string name="preferences_webdav_last_result_format">%3$s\n%1$s\n%2$s</string>
<string name="preferences_webdav_help">使用 WebDAV 資料夾 URL 時會儲存 mi-notes-sync.json;也可以填寫以 mi-notes-sync.json 結尾的完整檔案 URL。衝突規則:遠端快照較新且本機沒有本地修改時下載;否則上傳本機便籤。本地匯入或覆蓋遠端前會寫入帶時間戳的備份。</string>
<string name="preferences_webdav_help">使用 WebDAV 資料夾 URL 時會儲存 mi-notes-sync.json;也可以填寫完整的 .json 檔案 URL,支援「小米便簽同步.json」這類中文檔名。衝突規則:遠端快照較新且本機沒有本地修改時下載;否則上傳本機便籤。本地匯入或覆蓋遠端前會寫入帶時間戳的備份。</string>
<string name="preferences_last_sync_time">上次同步于 %1$s</string>
<string name="preferences_last_sync_time_format">yyyy-MM-dd HH:mm:ss</string>
<string name="preferences_last_sync_time_never">上次同步:尚未同步</string>
Expand Down
Loading
Loading