diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 390e79f0..94ea6896 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -38,7 +38,7 @@ android { buildTypes { debug { applicationIdSuffix = ".dev" - resValue("string", "app_name", "Hackers\\' Pub Dev") + resValue("string", "app_name", "Hackers Pub Dev") } release { resValue("string", "app_name", "Hackers\\' Pub") diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 88a8c173..dd48bcd6 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -27,6 +27,11 @@ + + + + + diff --git a/app/src/main/java/pub/hackers/android/MainActivity.kt b/app/src/main/java/pub/hackers/android/MainActivity.kt index d69dff2f..8a0502ff 100644 --- a/app/src/main/java/pub/hackers/android/MainActivity.kt +++ b/app/src/main/java/pub/hackers/android/MainActivity.kt @@ -32,7 +32,9 @@ import pub.hackers.android.data.local.PreferencesManager import pub.hackers.android.data.local.SessionManager import pub.hackers.android.navigation.HackersPubRoute import pub.hackers.android.navigation.HackersPubUrlRouter +import pub.hackers.android.navigation.ShareTargetText import pub.hackers.android.navigation.toNavRoute +import pub.hackers.android.ui.DetailScreen import pub.hackers.android.ui.HackersPubApp import pub.hackers.android.ui.theme.HackersPubTheme import pub.hackers.android.ui.theme.LocalAppColors @@ -85,6 +87,7 @@ class MainActivity : ComponentActivity() { super.onCreate(savedInstanceState) if (savedInstanceState == null) { handleDeepLink(intent) + handleShareIntent(intent) handleNavigationIntent(intent) } requestNotificationPermissionIfNeeded() @@ -117,6 +120,7 @@ class MainActivity : ComponentActivity() { override fun onNewIntent(intent: Intent) { super.onNewIntent(intent) handleDeepLink(intent) + handleShareIntent(intent) handleNavigationIntent(intent) } @@ -168,6 +172,20 @@ class MainActivity : ComponentActivity() { navigationIntent = NavigationIntent(route = route) } + private fun handleShareIntent(intent: Intent?) { + if (intent?.action != Intent.ACTION_SEND) return + if (intent.type != "text/plain") return + + val sharedText = ShareTargetText.format( + subject = intent.getCharSequenceExtra(Intent.EXTRA_SUBJECT), + text = intent.getCharSequenceExtra(Intent.EXTRA_TEXT), + ) ?: return + + navigationIntent = NavigationIntent( + route = DetailScreen.Compose.createRoute(prefill = sharedText) + ) + } + private fun handleDeepLink(intent: Intent?) { val data = intent?.data ?: return diff --git a/app/src/main/java/pub/hackers/android/navigation/ShareTargetText.kt b/app/src/main/java/pub/hackers/android/navigation/ShareTargetText.kt new file mode 100644 index 00000000..9a13646e --- /dev/null +++ b/app/src/main/java/pub/hackers/android/navigation/ShareTargetText.kt @@ -0,0 +1,14 @@ +package pub.hackers.android.navigation + +internal object ShareTargetText { + fun format(subject: CharSequence?, text: CharSequence?): String? { + val sharedText = text?.toString()?.takeIf { it.isNotBlank() } ?: return null + val sharedSubject = subject?.toString()?.takeIf { it.isNotBlank() } + + return if (sharedSubject != null) { + "$sharedSubject\n\n$sharedText" + } else { + sharedText + } + } +} diff --git a/app/src/main/java/pub/hackers/android/ui/HackersPubApp.kt b/app/src/main/java/pub/hackers/android/ui/HackersPubApp.kt index 0def6b38..f6eef523 100644 --- a/app/src/main/java/pub/hackers/android/ui/HackersPubApp.kt +++ b/app/src/main/java/pub/hackers/android/ui/HackersPubApp.kt @@ -116,11 +116,12 @@ sealed class DetailScreen(val route: String) { return if (params.isEmpty()) "signin" else "signin?${params.joinToString("&")}" } } - data object Compose : DetailScreen("compose?replyTo={replyTo}"eOf={quoteOf}") { - fun createRoute(replyTo: String? = null, quoteOf: String? = null): String { + data object Compose : DetailScreen("compose?replyTo={replyTo}"eOf={quoteOf}&prefill={prefill}") { + fun createRoute(replyTo: String? = null, quoteOf: String? = null, prefill: String? = null): String { val params = mutableListOf() - if (replyTo != null) params.add("replyTo=$replyTo") - if (quoteOf != null) params.add("quoteOf=$quoteOf") + if (replyTo != null) params.add("replyTo=${android.net.Uri.encode(replyTo)}") + if (quoteOf != null) params.add("quoteOf=${android.net.Uri.encode(quoteOf)}") + if (prefill != null) params.add("prefill=${android.net.Uri.encode(prefill)}") return if (params.isEmpty()) "compose" else "compose?${params.joinToString("&")}" } } @@ -487,14 +488,21 @@ fun HackersPubApp( type = NavType.StringType nullable = true defaultValue = null + }, + navArgument("prefill") { + type = NavType.StringType + nullable = true + defaultValue = null } ) ) { backStackEntry -> val replyTo = backStackEntry.arguments?.getString("replyTo") val quoteOf = backStackEntry.arguments?.getString("quoteOf") + val prefill = backStackEntry.arguments?.getString("prefill") ComposeScreen( replyToId = replyTo, quotedPostId = quoteOf, + initialContent = prefill, onPostSuccess = { viewModel.timelineRefreshTrigger.requestRefresh() navController.popBackStack() diff --git a/app/src/main/java/pub/hackers/android/ui/screens/compose/ComposeScreen.kt b/app/src/main/java/pub/hackers/android/ui/screens/compose/ComposeScreen.kt index 5f182f5f..c0f3f697 100644 --- a/app/src/main/java/pub/hackers/android/ui/screens/compose/ComposeScreen.kt +++ b/app/src/main/java/pub/hackers/android/ui/screens/compose/ComposeScreen.kt @@ -140,6 +140,7 @@ private fun calculateMentionPopupOffset( fun ComposeScreen( replyToId: String?, quotedPostId: String? = null, + initialContent: String? = null, onPostSuccess: () -> Unit, onNavigateBack: () -> Unit, viewModel: ComposeViewModel = hiltViewModel() @@ -235,6 +236,10 @@ fun ComposeScreen( quotedPostId?.let { viewModel.setQuotedPost(it) } } + LaunchedEffect(initialContent) { + initialContent?.let { viewModel.setInitialContent(it) } + } + LaunchedEffect(uiState.isPosted) { if (uiState.isPosted) { onPostSuccess() diff --git a/app/src/main/java/pub/hackers/android/ui/screens/compose/ComposeViewModel.kt b/app/src/main/java/pub/hackers/android/ui/screens/compose/ComposeViewModel.kt index f1544ad8..0cd8f039 100644 --- a/app/src/main/java/pub/hackers/android/ui/screens/compose/ComposeViewModel.kt +++ b/app/src/main/java/pub/hackers/android/ui/screens/compose/ComposeViewModel.kt @@ -299,6 +299,22 @@ class ComposeViewModel @Inject constructor( } } + fun setInitialContent(content: String) { + if (content.isBlank()) return + + _uiState.update { + if (it.content.isBlank()) { + it.copy( + content = content, + cursorPosition = content.length, + ) + } else { + it + } + } + detectLanguage(content) + } + fun updateContent(content: String, cursorPosition: Int = content.length) { _uiState.update { it.copy(content = content, cursorPosition = cursorPosition) } diff --git a/app/src/test/java/pub/hackers/android/navigation/ShareTargetTextTest.kt b/app/src/test/java/pub/hackers/android/navigation/ShareTargetTextTest.kt new file mode 100644 index 00000000..50dc5ba8 --- /dev/null +++ b/app/src/test/java/pub/hackers/android/navigation/ShareTargetTextTest.kt @@ -0,0 +1,37 @@ +package pub.hackers.android.navigation + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class ShareTargetTextTest { + @Test + fun `formats subject and text separated by blank line`() { + val result = ShareTargetText.format( + subject = "Article title", + text = "https://example.com/article", + ) + + assertEquals("Article title\n\nhttps://example.com/article", result) + } + + @Test + fun `uses text as-is when subject is missing`() { + val result = ShareTargetText.format( + subject = null, + text = " https://example.com/article\n", + ) + + assertEquals(" https://example.com/article\n", result) + } + + @Test + fun `ignores subject without shared text`() { + val result = ShareTargetText.format( + subject = "Article title", + text = " ", + ) + + assertNull(result) + } +} diff --git a/app/src/test/java/pub/hackers/android/ui/screens/compose/ComposeViewModelTest.kt b/app/src/test/java/pub/hackers/android/ui/screens/compose/ComposeViewModelTest.kt index 38a4d330..9a8b61c8 100644 --- a/app/src/test/java/pub/hackers/android/ui/screens/compose/ComposeViewModelTest.kt +++ b/app/src/test/java/pub/hackers/android/ui/screens/compose/ComposeViewModelTest.kt @@ -123,6 +123,28 @@ class ComposeViewModelTest { assertEquals("user typed this", vm.uiState.value.content) } + @Test + fun `setInitialContent preloads blank composer`() = runTest { + val vm = newViewModel() + advanceUntilIdle() + + vm.setInitialContent("Shared title\n\nhttps://example.com") + + assertEquals("Shared title\n\nhttps://example.com", vm.uiState.value.content) + assertEquals("Shared title\n\nhttps://example.com".length, vm.uiState.value.cursorPosition) + } + + @Test + fun `setInitialContent does not overwrite typed content`() = runTest { + val vm = newViewModel() + advanceUntilIdle() + + vm.updateContent("user typed this") + vm.setInitialContent("shared content") + + assertEquals("user typed this", vm.uiState.value.content) + } + @Test fun `post sends selected quote policy for public notes`() = runTest { val createdPost = samplePost(id = "created")