diff --git a/core/network/build.gradle.kts b/core/network/build.gradle.kts index 51a148a..50edc21 100644 --- a/core/network/build.gradle.kts +++ b/core/network/build.gradle.kts @@ -23,4 +23,5 @@ dependencies { implementation(libs.kotlinx.coroutines.android) testImplementation(libs.junit) + testImplementation(libs.okhttp.mockwebserver) } diff --git a/core/network/src/main/java/com/whtis/memosly/core/network/api/MemoApi.kt b/core/network/src/main/java/com/whtis/memosly/core/network/api/MemoApi.kt index 20d1929..bd5e0d6 100644 --- a/core/network/src/main/java/com/whtis/memosly/core/network/api/MemoApi.kt +++ b/core/network/src/main/java/com/whtis/memosly/core/network/api/MemoApi.kt @@ -79,8 +79,9 @@ interface MemoApi { @Body request: SetMemoRelationsRequest, ) - // v0.24: link resources to memo - @POST("api/v1/memos/{id}/resources") + // v0.24: link resources to memo. PATCH, not POST — the proto binds this to + // `patch: "/api/v1/{name=memos/*}/resources"`, and POST answers 501. + @PATCH("api/v1/memos/{id}/resources") suspend fun setMemoResources( @Path("id") id: String, @Body request: SetMemoResourcesRequest, diff --git a/core/network/src/test/java/com/whtis/memosly/core/network/api/MemoApiAttachmentEndpointTest.kt b/core/network/src/test/java/com/whtis/memosly/core/network/api/MemoApiAttachmentEndpointTest.kt new file mode 100644 index 0000000..d8b4b82 --- /dev/null +++ b/core/network/src/test/java/com/whtis/memosly/core/network/api/MemoApiAttachmentEndpointTest.kt @@ -0,0 +1,90 @@ +package com.whtis.memosly.core.network.api + +import com.squareup.moshi.Moshi +import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory +import com.whtis.memosly.core.network.dto.ResourceRef +import com.whtis.memosly.core.network.dto.SetMemoAttachmentsRequest +import com.whtis.memosly.core.network.dto.SetMemoResourcesRequest +import kotlinx.coroutines.runBlocking +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Test +import retrofit2.Retrofit +import retrofit2.converter.moshi.MoshiConverterFactory + +/** + * Pins the HTTP method and path used to link uploaded files to a memo. + * + * Both calls are bound to `patch:` in the Memos proto. Sending POST to the v0.24 + * endpoint answers 501 and the upload stays orphaned — server-side it shows up as an + * unused resource, and the memo renders without its images on every client. That + * shipped, because a wrong verb fails quietly and looks exactly like a save that worked. + */ +class MemoApiAttachmentEndpointTest { + + private lateinit var server: MockWebServer + private lateinit var api: MemoApi + + @Before + fun setUp() { + server = MockWebServer() + server.start() + api = Retrofit.Builder() + .baseUrl(server.url("/")) + .addConverterFactory( + MoshiConverterFactory.create( + Moshi.Builder().add(KotlinJsonAdapterFactory()).build() + ) + ) + .build() + .create(MemoApi::class.java) + } + + @After + fun tearDown() { + server.shutdown() + } + + @Test + fun `v024 setMemoResources patches the resources endpoint`() { + server.enqueue(MockResponse().setResponseCode(200).setBody("{}")) + + runBlocking { + api.setMemoResources( + "4fLRfWCwya4YA2CvnBmWZY", + SetMemoResourcesRequest(listOf(ResourceRef("resources/R6dURNySEeeTUbaCz43Ft2"))), + ) + } + + val request = server.takeRequest() + assertEquals("PATCH", request.method) + assertEquals("/api/v1/memos/4fLRfWCwya4YA2CvnBmWZY/resources", request.path) + assertEquals( + """{"resources":[{"name":"resources/R6dURNySEeeTUbaCz43Ft2"}]}""", + request.body.readUtf8(), + ) + } + + @Test + fun `v025 and up setMemoAttachments patches the attachments endpoint`() { + server.enqueue(MockResponse().setResponseCode(200).setBody("{}")) + + runBlocking { + api.setMemoAttachments( + "bsveqyE5j3H6YXvWBXuzBP", + SetMemoAttachmentsRequest(listOf(ResourceRef("attachments/Bc8eUUhk7jCGLgSkKrArYW"))), + ) + } + + val request = server.takeRequest() + assertEquals("PATCH", request.method) + assertEquals("/api/v1/memos/bsveqyE5j3H6YXvWBXuzBP/attachments", request.path) + assertEquals( + """{"attachments":[{"name":"attachments/Bc8eUUhk7jCGLgSkKrArYW"}]}""", + request.body.readUtf8(), + ) + } +} diff --git a/core/ui/src/main/res/values-zh/strings.xml b/core/ui/src/main/res/values-zh/strings.xml index 33ff921..fcf1838 100644 --- a/core/ui/src/main/res/values-zh/strings.xml +++ b/core/ui/src/main/res/values-zh/strings.xml @@ -147,6 +147,7 @@ 已跳过 %1$s — 超过 %2$d MB 无法读取 %1$s 仅添加了前 %1$d 个文件 + 备忘已保存,但附件关联失败。点击保存可重试。 下载 diff --git a/core/ui/src/main/res/values/strings.xml b/core/ui/src/main/res/values/strings.xml index d4275d8..6565d99 100644 --- a/core/ui/src/main/res/values/strings.xml +++ b/core/ui/src/main/res/values/strings.xml @@ -148,6 +148,7 @@ Skipped %1$s — larger than %2$d MB Couldn\'t read %1$s Only the first %1$d files were added + The memo was saved, but its attachments could not be linked. Tap save to retry. Download diff --git a/feature/memo/src/main/java/com/whtis/memosly/feature/memo/MemoEditorViewModel.kt b/feature/memo/src/main/java/com/whtis/memosly/feature/memo/MemoEditorViewModel.kt index d1486e4..1db0822 100644 --- a/feature/memo/src/main/java/com/whtis/memosly/feature/memo/MemoEditorViewModel.kt +++ b/feature/memo/src/main/java/com/whtis/memosly/feature/memo/MemoEditorViewModel.kt @@ -71,7 +71,11 @@ class MemoEditorViewModel @Inject constructor( val serverUrl: String get() = tokenManager.serverUrl.value ?: "" - private val memoId: String = savedStateHandle["memoId"] ?: "" + /** + * Blank for a new memo. Once a create succeeds this holds the new id, so a retry after a + * failed attachment link updates that memo instead of creating a second one. + */ + private var memoId: String = savedStateHandle["memoId"] ?: "" private val sharedText: String = savedStateHandle["sharedText"] ?: "" private val hasSharedMedia: Boolean = savedStateHandle["sharedMedia"] ?: false @@ -236,10 +240,12 @@ class MemoEditorViewModel @Inject constructor( _uiState.value = _uiState.value.copy(isSaving = true, error = null) try { val visibilityStr = _uiState.value.visibility.name - val memo = if (memoId.isNotBlank()) { + val isUpdate = memoId.isNotBlank() + val memo = if (isUpdate) { memoRepository.updateMemo(memoId, content, visibilityStr) } else { memoRepository.createMemo(content, visibilityStr).also { + memoId = it.name.substringAfterLast("/") analyticsHelper.logEvent("memo_create", mapOf( "has_tags" to if (content.extractTags().isNotEmpty()) "true" else "false", )) @@ -254,7 +260,7 @@ class MemoEditorViewModel @Inject constructor( // them now just orphans them. val pendingResources = _uiState.value.pendingResources val existingResources = _uiState.value.existingResources - val existingChanged = memoId.isNotBlank() && existingResources.size != memo.resources.size + val existingChanged = isUpdate && existingResources.size != memo.resources.size if (pendingResources.isNotEmpty() || existingChanged) { try { val existingNames = existingResources.map { it.name } @@ -262,7 +268,19 @@ class MemoEditorViewModel @Inject constructor( val allNames = (existingNames + newNames).distinct() memoRepository.setMemoResources(memo.name, allNames) } catch (e: Exception) { + // Swallowing this is what let the v0.24 verb bug ship: the memo saved, + // the upload vanished, and nothing said so. The memo exists either way, + // so stay in the editor and let the user retry — memoId now points at + // it, so retrying updates rather than posting a duplicate. android.util.Log.e("MemoEditor", "setMemoResources failed: ${e.message}", e) + _uiState.update { + it.copy( + isSaving = false, + isEditMode = true, + error = context.getString(UiR.string.attachment_link_failed), + ) + } + return@launch } } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 46cca08..ce36083 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -122,6 +122,7 @@ kotlinx-coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx- # Testing junit = { group = "junit", name = "junit", version.ref = "junit" } +okhttp-mockwebserver = { group = "com.squareup.okhttp3", name = "mockwebserver", version.ref = "okhttp" } [plugins] android-application = { id = "com.android.application", version.ref = "agp" }