diff --git a/amethyst/build.gradle b/amethyst/build.gradle index 57630fc71..f52cb777c 100644 --- a/amethyst/build.gradle +++ b/amethyst/build.gradle @@ -338,6 +338,11 @@ dependencies { // Google services model the translate text playImplementation libs.google.mlkit.translate + // On-device AI writing assistance (Gemini Nano via AICore) + playImplementation libs.google.mlkit.genai.proofreading + playImplementation libs.google.mlkit.genai.prompt + playImplementation libs.google.mlkit.genai.rewriting + // PushNotifications playImplementation platform(libs.firebase.bom) playImplementation libs.firebase.messaging diff --git a/amethyst/src/fdroid/java/com/vitorpamplona/amethyst/service/ai/NoOpWritingAssistant.kt b/amethyst/src/fdroid/java/com/vitorpamplona/amethyst/service/ai/NoOpWritingAssistant.kt new file mode 100644 index 000000000..665cce0c3 --- /dev/null +++ b/amethyst/src/fdroid/java/com/vitorpamplona/amethyst/service/ai/NoOpWritingAssistant.kt @@ -0,0 +1,32 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.service.ai + +class NoOpWritingAssistant : WritingAssistant { + override suspend fun checkAvailability(): WritingAssistantStatus = WritingAssistantStatus.Unavailable + + override suspend fun transform( + text: String, + tone: WritingTone, + ): WritingResult = WritingResult(text, text, tone) + + override fun close() {} +} diff --git a/amethyst/src/fdroid/java/com/vitorpamplona/amethyst/service/ai/WritingAssistantFactory.kt b/amethyst/src/fdroid/java/com/vitorpamplona/amethyst/service/ai/WritingAssistantFactory.kt new file mode 100644 index 000000000..bc5e51758 --- /dev/null +++ b/amethyst/src/fdroid/java/com/vitorpamplona/amethyst/service/ai/WritingAssistantFactory.kt @@ -0,0 +1,28 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.service.ai + +import android.content.Context + +object WritingAssistantFactory { + @Suppress("UNUSED_PARAMETER") + fun create(context: Context): WritingAssistant = NoOpWritingAssistant() +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/UiSettings.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/UiSettings.kt index b0e6aae20..002b2935f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/UiSettings.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/UiSettings.kt @@ -38,6 +38,7 @@ data class UiSettings( val dontAskForNotificationPermissions: Boolean = false, val featureSet: FeatureSetType = FeatureSetType.SIMPLIFIED, val gallerySet: ProfileGalleryType = ProfileGalleryType.CLASSIC, + val automaticallyProposeAiImprovements: BooleanType = BooleanType.ALWAYS, ) enum class ThemeType( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/UiSettingsFlow.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/UiSettingsFlow.kt index 467663f7e..37cb82cc3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/UiSettingsFlow.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/UiSettingsFlow.kt @@ -38,6 +38,7 @@ class UiSettingsFlow( val dontAskForNotificationPermissions: MutableStateFlow = MutableStateFlow(false), val featureSet: MutableStateFlow = MutableStateFlow(FeatureSetType.SIMPLIFIED), val gallerySet: MutableStateFlow = MutableStateFlow(ProfileGalleryType.CLASSIC), + val automaticallyProposeAiImprovements: MutableStateFlow = MutableStateFlow(BooleanType.ALWAYS), ) { val listOfFlows: List> = listOf>( @@ -52,6 +53,7 @@ class UiSettingsFlow( dontAskForNotificationPermissions, featureSet, gallerySet, + automaticallyProposeAiImprovements, ) // emits at every change in any of the propertyes. @@ -69,6 +71,7 @@ class UiSettingsFlow( flows[8] as Boolean, flows[9] as FeatureSetType, flows[10] as ProfileGalleryType, + flows[11] as BooleanType, ) } @@ -85,6 +88,7 @@ class UiSettingsFlow( dontAskForNotificationPermissions.value, featureSet.value, gallerySet.value, + automaticallyProposeAiImprovements.value, ) fun update(torSettings: UiSettings): Boolean { @@ -134,6 +138,10 @@ class UiSettingsFlow( gallerySet.tryEmit(torSettings.gallerySet) any = true } + if (automaticallyProposeAiImprovements.value != torSettings.automaticallyProposeAiImprovements) { + automaticallyProposeAiImprovements.tryEmit(torSettings.automaticallyProposeAiImprovements) + any = true + } return any } @@ -164,6 +172,7 @@ class UiSettingsFlow( MutableStateFlow(uiSettings.dontAskForNotificationPermissions), MutableStateFlow(uiSettings.featureSet), MutableStateFlow(uiSettings.gallerySet), + MutableStateFlow(uiSettings.automaticallyProposeAiImprovements), ) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/UISharedPreferences.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/UISharedPreferences.kt index 2b673eb36..c2f573f41 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/UISharedPreferences.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/UISharedPreferences.kt @@ -102,6 +102,7 @@ class UiSharedPreferences( val UI_DONT_ASK_FOR_NOTIFICATION_PERMISSIONS = booleanPreferencesKey("ui.dont_ask_for_notification_permissions") val UI_FEATURE_SET = stringPreferencesKey("ui.feature_set") val UI_GALLERY_SET = stringPreferencesKey("ui.gallery_set") + val UI_PROPOSE_AI_IMPROVEMENTS = stringPreferencesKey("ui.propose_ai_improvements") suspend fun uiPreferences(context: Context): UiSettings? = try { @@ -120,6 +121,7 @@ class UiSharedPreferences( dontAskForNotificationPermissions = preferences[UI_DONT_ASK_FOR_NOTIFICATION_PERMISSIONS] ?: false, featureSet = preferences[UI_FEATURE_SET]?.let { FeatureSetType.valueOf(it) } ?: FeatureSetType.SIMPLIFIED, gallerySet = preferences[UI_GALLERY_SET]?.let { ProfileGalleryType.valueOf(it) } ?: ProfileGalleryType.CLASSIC, + automaticallyProposeAiImprovements = preferences[UI_PROPOSE_AI_IMPROVEMENTS]?.let { BooleanType.valueOf(it) } ?: BooleanType.ALWAYS, ) } catch (e: Exception) { if (e is CancellationException) throw e @@ -155,6 +157,7 @@ class UiSharedPreferences( preferences[UI_DONT_ASK_FOR_NOTIFICATION_PERMISSIONS] = sharedSettings.dontAskForNotificationPermissions preferences[UI_FEATURE_SET] = sharedSettings.featureSet.name preferences[UI_GALLERY_SET] = sharedSettings.gallerySet.name + preferences[UI_PROPOSE_AI_IMPROVEMENTS] = sharedSettings.automaticallyProposeAiImprovements.name } } catch (e: Exception) { if (e is CancellationException) throw e diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/ai/MockWritingAssistant.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/ai/MockWritingAssistant.kt new file mode 100644 index 000000000..8945613fe --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/ai/MockWritingAssistant.kt @@ -0,0 +1,97 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.service.ai + +import kotlinx.coroutines.delay + +/** + * TODO: Remove before shipping. Debug-only mock for testing the AI writing help UI + * on devices without Gemini Nano / AICore support. + */ +class MockWritingAssistant : WritingAssistant { + override suspend fun checkAvailability(): WritingAssistantStatus = WritingAssistantStatus.Available + + override suspend fun transform( + text: String, + tone: WritingTone, + ): WritingResult { + delay(800) + + val transformed = + when (tone) { + WritingTone.CORRECT -> { + correctMock(text) + } + + WritingTone.REPHRASE -> { + "Here's another way to put it: $text" + } + + WritingTone.SHORTER -> { + text + .split(".") + .firstOrNull() + ?.trim() + ?.plus(".") ?: text + } + + WritingTone.ELABORATE -> { + "$text Furthermore, this point deserves deeper consideration and nuance." + } + + WritingTone.FRIENDLY -> { + "Hey! $text Hope that makes sense! :)" + } + + WritingTone.PROFESSIONAL -> { + "I would like to bring to your attention the following: $text" + } + + WritingTone.MORE_DIRECT -> { + text.replace("I think ", "").replace("maybe ", "").replace("perhaps ", "") + } + + WritingTone.PUNCHY -> { + text.uppercase().replace(".", "!") + } + + WritingTone.EMOJIFY -> { + "$text \uD83D\uDE80\uD83D\uDD25\u2728" + } + } + + return WritingResult( + originalText = text, + transformedText = transformed, + tone = tone, + ) + } + + private fun correctMock(text: String): String = + text + .replace("teh ", "the ") + .replace("dont ", "don't ") + .replace("cant ", "can't ") + .replace("wont ", "won't ") + .replace("i ", "I ") + + override fun close() {} +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/ai/WritingAssistant.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/ai/WritingAssistant.kt new file mode 100644 index 000000000..e136ecba1 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/ai/WritingAssistant.kt @@ -0,0 +1,61 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.service.ai + +import androidx.compose.runtime.Immutable + +interface WritingAssistant { + suspend fun checkAvailability(): WritingAssistantStatus + + suspend fun transform( + text: String, + tone: WritingTone, + ): WritingResult + + fun close() +} + +enum class WritingTone { + CORRECT, + REPHRASE, + SHORTER, + ELABORATE, + FRIENDLY, + PROFESSIONAL, + MORE_DIRECT, + PUNCHY, + EMOJIFY, +} + +sealed class WritingAssistantStatus { + data object Available : WritingAssistantStatus() + + data object Unavailable : WritingAssistantStatus() + + data object Downloading : WritingAssistantStatus() +} + +@Immutable +data class WritingResult( + val originalText: String, + val transformedText: String, + val tone: WritingTone, +) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/aihelp/AiWritingHelpButton.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/aihelp/AiWritingHelpButton.kt new file mode 100644 index 000000000..c2e2a7b61 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/aihelp/AiWritingHelpButton.kt @@ -0,0 +1,55 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.note.creators.aihelp + +import androidx.compose.foundation.layout.height +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.AutoAwesome +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.ui.stringRes + +@Composable +fun AiWritingHelpButton( + isActive: Boolean, + onClick: () -> Unit, +) { + IconButton( + onClick = { onClick() }, + ) { + Icon( + imageVector = Icons.Outlined.AutoAwesome, + contentDescription = stringRes(R.string.ai_writing_help), + modifier = Modifier.height(22.dp), + tint = + if (isActive) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onBackground + }, + ) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/aihelp/AiWritingHelpPanel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/aihelp/AiWritingHelpPanel.kt new file mode 100644 index 000000000..7eb2e6cbb --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/aihelp/AiWritingHelpPanel.kt @@ -0,0 +1,169 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.note.creators.aihelp + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.expandVertically +import androidx.compose.animation.shrinkVertically +import androidx.compose.foundation.background +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.FilterChip +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.service.ai.WritingResult +import com.vitorpamplona.amethyst.service.ai.WritingTone +import com.vitorpamplona.amethyst.ui.stringRes + +@Composable +fun AiWritingHelpPanel( + isVisible: Boolean, + readyResults: Map, + selectedResult: WritingResult?, + onToneSelected: (WritingTone) -> Unit, + onApply: () -> Unit, + onDismiss: () -> Unit, +) { + AnimatedVisibility( + visible = isVisible, + enter = expandVertically(), + exit = shrinkVertically(), + ) { + Column( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp), + ) { + selectedResult?.let { + AiResultCard( + result = it, + onApply = onApply, + onDismiss = onDismiss, + ) + } + + ToneChipRow( + readyTones = readyResults.keys, + selectedTone = selectedResult?.tone, + onToneSelected = onToneSelected, + ) + } + } +} + +@Composable +private fun AiResultCard( + result: WritingResult, + onApply: () -> Unit, + onDismiss: () -> Unit, +) { + Column( + modifier = + Modifier + .fillMaxWidth() + .padding(vertical = 4.dp) + .clip(RoundedCornerShape(12.dp)) + .background(MaterialTheme.colorScheme.surfaceVariant) + .padding(12.dp), + ) { + Text( + text = result.transformedText, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = + Modifier + .fillMaxWidth() + .heightIn(max = 150.dp) + .verticalScroll(rememberScrollState()), + ) + + Row( + modifier = + Modifier + .fillMaxWidth() + .padding(top = 8.dp), + horizontalArrangement = Arrangement.End, + ) { + TextButton(onClick = onDismiss) { + Text(stringRes(R.string.ai_writing_dismiss)) + } + OutlinedButton(onClick = onApply) { + Text(stringRes(R.string.ai_writing_use_this)) + } + } + } +} + +@Composable +private fun ToneChipRow( + readyTones: Set, + selectedTone: WritingTone?, + onToneSelected: (WritingTone) -> Unit, +) { + val scrollState = rememberScrollState() + Row( + modifier = + Modifier + .horizontalScroll(scrollState) + .fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + WritingTone.entries.forEach { tone -> + if (tone in readyTones) { + FilterChip( + selected = tone == selectedTone, + onClick = { onToneSelected(tone) }, + label = { Text(toneDisplayName(tone)) }, + ) + } + } + } +} + +@Composable +private fun toneDisplayName(tone: WritingTone): String = + when (tone) { + WritingTone.CORRECT -> stringRes(R.string.ai_tone_correct) + WritingTone.REPHRASE -> stringRes(R.string.ai_tone_rephrase) + WritingTone.SHORTER -> stringRes(R.string.ai_tone_shorter) + WritingTone.ELABORATE -> stringRes(R.string.ai_tone_elaborate) + WritingTone.FRIENDLY -> stringRes(R.string.ai_tone_friendly) + WritingTone.PROFESSIONAL -> stringRes(R.string.ai_tone_professional) + WritingTone.MORE_DIRECT -> stringRes(R.string.ai_tone_more_direct) + WritingTone.PUNCHY -> stringRes(R.string.ai_tone_punchy) + WritingTone.EMOJIFY -> stringRes(R.string.ai_tone_emojify) + } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostScreen.kt index eed62305a..4816efd86 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostScreen.kt @@ -82,6 +82,7 @@ import com.vitorpamplona.amethyst.ui.navigation.navs.Nav import com.vitorpamplona.amethyst.ui.navigation.topbars.PostingTopBar import com.vitorpamplona.amethyst.ui.note.BaseUserPicture import com.vitorpamplona.amethyst.ui.note.NoteCompose +import com.vitorpamplona.amethyst.ui.note.creators.aihelp.AiWritingHelpPanel import com.vitorpamplona.amethyst.ui.note.creators.contentWarning.ContentSensitivityExplainer import com.vitorpamplona.amethyst.ui.note.creators.contentWarning.MarkAsSensitiveButton import com.vitorpamplona.amethyst.ui.note.creators.emojiSuggestions.ShowEmojiSuggestionList @@ -144,6 +145,10 @@ fun ShortNotePostScreen( val context = LocalContext.current val activity = context.getActivity() + LaunchedEffect(Unit) { + postViewModel.initWritingAssistant(context) + } + LaunchedEffect(postViewModel, accountViewModel) { val baseReplyTo = baseReplyToId?.let { accountViewModel.getNoteIfExists(it) } val quote = quoteId?.let { accountViewModel.getNoteIfExists(it) } @@ -556,6 +561,15 @@ private fun NewPostScreenBody( ) } + AiWritingHelpPanel( + isVisible = postViewModel.showAiPanel, + readyResults = postViewModel.aiResults, + selectedResult = postViewModel.aiSelectedResult, + onToneSelected = postViewModel::selectAiResult, + onApply = postViewModel::applyAiResult, + onDismiss = postViewModel::dismissAiResult, + ) + BottomRowActions(postViewModel) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt index 6adf94284..d3c960e10 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt @@ -41,9 +41,16 @@ import com.vitorpamplona.amethyst.commons.compose.replaceCurrentWord import com.vitorpamplona.amethyst.commons.compose.setTextAndPlaceCursorAtBeginning import com.vitorpamplona.amethyst.commons.model.nip30CustomEmojis.EmojiPackState.EmojiMedia import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.model.BooleanType import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.service.ai.MockWritingAssistant +import com.vitorpamplona.amethyst.service.ai.WritingAssistant +import com.vitorpamplona.amethyst.service.ai.WritingAssistantFactory +import com.vitorpamplona.amethyst.service.ai.WritingAssistantStatus +import com.vitorpamplona.amethyst.service.ai.WritingResult +import com.vitorpamplona.amethyst.service.ai.WritingTone import com.vitorpamplona.amethyst.service.location.LocationState import com.vitorpamplona.amethyst.service.uploads.CompressorQuality import com.vitorpamplona.amethyst.service.uploads.MediaCompressor @@ -140,7 +147,13 @@ import com.vitorpamplona.quartz.utils.Log import com.vitorpamplona.quartz.utils.RandomInstance import com.vitorpamplona.quartz.utils.TimeUtils import kotlinx.collections.immutable.ImmutableList +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch @@ -291,6 +304,96 @@ open class ShortNotePostViewModel : // Anonymous Reply var wantsAnonymousPost by mutableStateOf(false) + // AI Writing Help for testing + private val useMockAi = false + + var aiResults by mutableStateOf>(emptyMap()) + var aiSelectedResult by mutableStateOf(null) + var aiStatus by mutableStateOf(WritingAssistantStatus.Unavailable) + private var writingAssistant: WritingAssistant? = null + private var aiComputeJob: Job? = null + private var lastComputedText: String = "" + + val showAiPanel: Boolean + get() { + val prefEnabled = + accountViewModel.settings.uiSettingsFlow.automaticallyProposeAiImprovements.value == + BooleanType.ALWAYS + return prefEnabled && aiStatus is WritingAssistantStatus.Available && aiResults.isNotEmpty() + } + + fun initWritingAssistant(context: android.content.Context) { + if (writingAssistant == null) { + writingAssistant = + if (useMockAi) { + MockWritingAssistant() + } else { + WritingAssistantFactory.create(context) + } + viewModelScope.launch(Dispatchers.IO) { + aiStatus = writingAssistant?.checkAvailability() ?: WritingAssistantStatus.Unavailable + } + } + } + + fun precomputeAiResults() { + val assistant = writingAssistant ?: return + if (aiStatus !is WritingAssistantStatus.Available) return + val prefEnabled = + accountViewModel.settings.uiSettingsFlow.automaticallyProposeAiImprovements.value == + BooleanType.ALWAYS + if (!prefEnabled) return + + val text = message.text.toString().trim() + if (text.isBlank() || text == lastComputedText) return + + aiComputeJob?.cancel() + aiResults = emptyMap() + aiSelectedResult = null + + aiComputeJob = + viewModelScope.launch(Dispatchers.IO) { + delay(1000) + lastComputedText = text + + val results = + coroutineScope { + WritingTone.entries + .map { tone -> + async { + try { + assistant.transform(text, tone) + } catch (e: Exception) { + if (e is CancellationException) throw e + null + } + } + }.awaitAll() + .filterNotNull() + .associateBy { it.tone } + } + aiResults = results + } + } + + fun selectAiResult(tone: WritingTone) { + aiSelectedResult = aiResults[tone] + } + + fun applyAiResult() { + aiSelectedResult?.let { + message.setTextAndPlaceCursorAtEnd(it.transformedText) + aiSelectedResult = null + aiResults = emptyMap() + lastComputedText = "" + draftTag.newVersion() + } + } + + fun dismissAiResult() { + aiSelectedResult = null + } + fun lnAddress(): String? = account.userProfile().lnAddress() fun hasLnAddress(): Boolean = account.userProfile().lnAddress() != null @@ -1131,6 +1234,7 @@ open class ShortNotePostViewModel : emojiSuggestions?.processCurrentWord(lastWord) } + precomputeAiResults() draftTag.newVersion() } @@ -1372,6 +1476,8 @@ open class ShortNotePostViewModel : override fun onCleared() { super.onCleared() + writingAssistant?.close() + writingAssistant = null Log.d("Init") { "OnCleared: ${this.javaClass.simpleName}" } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/AppSettingsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/AppSettingsScreen.kt index fc799a4a0..ace4f154d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/AppSettingsScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/AppSettingsScreen.kt @@ -121,6 +121,7 @@ fun SettingsScreen(sharedPrefs: UiSettingsFlow) { ImmersiveScrollingChoice(sharedPrefs) FeatureSetChoice(sharedPrefs) GalleryChoice(sharedPrefs) + AiWritingHelpChoice(sharedPrefs) PushNotificationSettingsRow(sharedPrefs) } } @@ -366,6 +367,26 @@ fun GalleryChoice(sharedPrefs: UiSettingsFlow) { } } +@Composable +fun AiWritingHelpChoice(sharedPrefs: UiSettingsFlow) { + val aiIndex by sharedPrefs.automaticallyProposeAiImprovements.collectAsState() + + val booleanItems = + persistentListOf( + TitleExplainer(stringRes(ConnectivityType.ALWAYS.resourceId)), + TitleExplainer(stringRes(ConnectivityType.NEVER.resourceId)), + ) + + SettingsRow( + R.string.ai_writing_setting_title, + R.string.ai_writing_setting_description, + booleanItems, + aiIndex.screenCode, + ) { + sharedPrefs.automaticallyProposeAiImprovements.tryEmit(parseBooleanType(it)) + } +} + @Composable fun SettingsRow( name: Int, diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 985b4f915..22e265c34 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -2199,4 +2199,19 @@ %1$d members removed from relay Relay join request Relay leave request + + AI Writing Help + Propose text improvements + Uses an on-device AI model to propose text corrections and tone changes. + Use This + Dismiss + Correct + Rephrase + Shorter + Elaborate + Friendly + Professional + More Direct + Punchy + + Emoji diff --git a/amethyst/src/play/java/com/vitorpamplona/amethyst/service/ai/MLKitWritingAssistant.kt b/amethyst/src/play/java/com/vitorpamplona/amethyst/service/ai/MLKitWritingAssistant.kt new file mode 100644 index 000000000..fec041625 --- /dev/null +++ b/amethyst/src/play/java/com/vitorpamplona/amethyst/service/ai/MLKitWritingAssistant.kt @@ -0,0 +1,169 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.service.ai + +import android.content.Context +import com.google.android.gms.tasks.Tasks +import com.google.mlkit.genai.common.FeatureStatus +import com.google.mlkit.genai.proofreading.Proofreader +import com.google.mlkit.genai.proofreading.ProofreaderOptions +import com.google.mlkit.genai.proofreading.Proofreading +import com.google.mlkit.genai.proofreading.ProofreadingRequest +import com.google.mlkit.genai.rewriting.Rewriter +import com.google.mlkit.genai.rewriting.RewriterOptions +import com.google.mlkit.genai.rewriting.Rewriting +import com.google.mlkit.genai.rewriting.RewritingRequest +import com.vitorpamplona.amethyst.service.lang.LanguageTranslatorService +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +class MLKitWritingAssistant( + private val context: Context, +) : WritingAssistant { + private var rewriters = mutableMapOf() + private var proofreaders = mutableMapOf() + + private fun rewriterCacheKey( + @RewriterOptions.OutputType outputType: Int, + @RewriterOptions.Language language: Int, + ): Long = (outputType.toLong() shl 32) or language.toLong() + + private fun getRewriter( + @RewriterOptions.OutputType outputType: Int, + @RewriterOptions.Language language: Int, + ): Rewriter = + rewriters.getOrPut(rewriterCacheKey(outputType, language)) { + Rewriting.getClient( + RewriterOptions + .builder(context) + .setOutputType(outputType) + .setLanguage(language) + .build(), + ) + } + + private fun getProofreader( + @ProofreaderOptions.Language language: Int, + ): Proofreader = + proofreaders.getOrPut(language) { + Proofreading.getClient( + ProofreaderOptions + .builder(context) + .setInputType(ProofreaderOptions.InputType.KEYBOARD) + .setLanguage(language) + .build(), + ) + } + + override suspend fun checkAvailability(): WritingAssistantStatus = + withContext(Dispatchers.IO) { + try { + val rewriter = getRewriter(RewriterOptions.OutputType.REPHRASE, RewriterOptions.Language.ENGLISH) + val status = rewriter.checkFeatureStatus().get() + when (status) { + FeatureStatus.AVAILABLE -> WritingAssistantStatus.Available + FeatureStatus.DOWNLOADING -> WritingAssistantStatus.Downloading + else -> WritingAssistantStatus.Unavailable + } + } catch (e: Exception) { + WritingAssistantStatus.Unavailable + } + } + + override suspend fun transform( + text: String, + tone: WritingTone, + ): WritingResult { + val language = detectLanguage(text) + val transformedText = + when (tone) { + WritingTone.CORRECT -> proofread(text, language) + WritingTone.REPHRASE -> rewrite(text, RewriterOptions.OutputType.REPHRASE, language) + WritingTone.SHORTER -> rewrite(text, RewriterOptions.OutputType.SHORTEN, language) + WritingTone.ELABORATE -> rewrite(text, RewriterOptions.OutputType.ELABORATE, language) + WritingTone.FRIENDLY -> rewrite(text, RewriterOptions.OutputType.FRIENDLY, language) + WritingTone.PROFESSIONAL -> rewrite(text, RewriterOptions.OutputType.PROFESSIONAL, language) + WritingTone.EMOJIFY -> rewrite(text, RewriterOptions.OutputType.EMOJIFY, language) + WritingTone.MORE_DIRECT -> rewrite(text, RewriterOptions.OutputType.PROFESSIONAL, language) + WritingTone.PUNCHY -> rewrite(text, RewriterOptions.OutputType.SHORTEN, language) + } + + return WritingResult( + originalText = text, + transformedText = transformedText, + tone = tone, + ) + } + + private suspend fun detectLanguage(text: String): Int = + withContext(Dispatchers.IO) { + try { + val langTag = Tasks.await(LanguageTranslatorService.identifyLanguage(text)) + mapLanguageTag(langTag) + } catch (e: Exception) { + RewriterOptions.Language.ENGLISH + } + } + + private suspend fun rewrite( + text: String, + @RewriterOptions.OutputType outputType: Int, + @RewriterOptions.Language language: Int, + ): String = + withContext(Dispatchers.IO) { + val rewriter = getRewriter(outputType, language) + val request = RewritingRequest.builder(text).build() + val result = rewriter.runInference(request).get() + result.results.firstOrNull()?.text ?: text + } + + private suspend fun proofread( + text: String, + @ProofreaderOptions.Language language: Int, + ): String = + withContext(Dispatchers.IO) { + val proofreader = getProofreader(language) + val request = ProofreadingRequest.builder(text).build() + val result = proofreader.runInference(request).get() + result.results.firstOrNull()?.text ?: text + } + + override fun close() { + rewriters.values.forEach { it.close() } + rewriters.clear() + proofreaders.values.forEach { it.close() } + proofreaders.clear() + } + + companion object { + fun mapLanguageTag(tag: String?): Int = + when (tag?.lowercase()?.take(2)) { + "en" -> RewriterOptions.Language.ENGLISH + "ja" -> RewriterOptions.Language.JAPANESE + "ko" -> RewriterOptions.Language.KOREAN + "de" -> RewriterOptions.Language.GERMAN + "fr" -> RewriterOptions.Language.FRENCH + "it" -> RewriterOptions.Language.ITALIAN + "es" -> RewriterOptions.Language.SPANISH + else -> RewriterOptions.Language.ENGLISH + } + } +} diff --git a/amethyst/src/play/java/com/vitorpamplona/amethyst/service/ai/WritingAssistantFactory.kt b/amethyst/src/play/java/com/vitorpamplona/amethyst/service/ai/WritingAssistantFactory.kt new file mode 100644 index 000000000..86525c2d9 --- /dev/null +++ b/amethyst/src/play/java/com/vitorpamplona/amethyst/service/ai/WritingAssistantFactory.kt @@ -0,0 +1,27 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.service.ai + +import android.content.Context + +object WritingAssistantFactory { + fun create(context: Context): WritingAssistant = MLKitWritingAssistant(context) +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 60485652c..0c0cc62f9 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -30,6 +30,9 @@ kotlin = "2.3.20" kotlinxCollectionsImmutable = "0.4.0" kotlinxCoroutinesCore = "1.10.2" kotlinxSerialization = "1.10.0" +genaiProofreading = "1.0.0-beta1" +genaiPrompt = "1.0.0-beta2" +genaiRewriting = "1.0.0-beta1" languageId = "17.0.6" lifecycleRuntimeKtx = "2.10.0" lightcompressor-enhanced = "1.8.1" @@ -134,6 +137,9 @@ jetbrains-compose-runtime = { module = "org.jetbrains.compose.runtime:runtime", jetbrains-compose-ui = { module = "org.jetbrains.compose.ui:ui", version.ref = "jetbrainsCompose" } jetbrains-compose-ui-tooling = { module = "org.jetbrains.compose.ui:ui-tooling", version.ref = "jetbrainsCompose" } jetbrains-compose-ui-tooling-preview = { module = "org.jetbrains.compose.ui:ui-tooling-preview", version.ref = "jetbrainsCompose" } +google-mlkit-genai-proofreading = { group = "com.google.mlkit", name = "genai-proofreading", version.ref = "genaiProofreading" } +google-mlkit-genai-prompt = { group = "com.google.mlkit", name = "genai-prompt", version.ref = "genaiPrompt" } +google-mlkit-genai-rewriting = { group = "com.google.mlkit", name = "genai-rewriting", version.ref = "genaiRewriting" } google-mlkit-language-id = { group = "com.google.mlkit", name = "language-id", version.ref = "languageId" } google-mlkit-translate = { group = "com.google.mlkit", name = "translate", version.ref = "translate" } jackson-module-kotlin = { group = "com.fasterxml.jackson.module", name = "jackson-module-kotlin", version.ref = "jacksonModuleKotlin" }