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 core/network/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -23,4 +23,5 @@ dependencies {
implementation(libs.kotlinx.coroutines.android)

testImplementation(libs.junit)
testImplementation(libs.okhttp.mockwebserver)
}
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
@@ -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(),
)
}
}
1 change: 1 addition & 0 deletions core/ui/src/main/res/values-zh/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,7 @@
<string name="media_too_large">已跳过 %1$s — 超过 %2$d MB</string>
<string name="media_unreadable">无法读取 %1$s</string>
<string name="media_too_many">仅添加了前 %1$d 个文件</string>
<string name="attachment_link_failed">备忘已保存,但附件关联失败。点击保存可重试。</string>

<!-- Media Viewer -->
<string name="download">下载</string>
Expand Down
1 change: 1 addition & 0 deletions core/ui/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,7 @@
<string name="media_too_large">Skipped %1$s — larger than %2$d MB</string>
<string name="media_unreadable">Couldn\'t read %1$s</string>
<string name="media_too_many">Only the first %1$d files were added</string>
<string name="attachment_link_failed">The memo was saved, but its attachments could not be linked. Tap save to retry.</string>

<!-- Media Viewer -->
<string name="download">Download</string>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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",
))
Expand All @@ -254,15 +260,27 @@ 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 }
val newNames = pendingResources.map { it.name }
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
}
}

Expand Down
1 change: 1 addition & 0 deletions gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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" }
Expand Down
Loading