Merge pull request #2176 from vitorpamplona/claude/ai-post-writing-help-CDPPw

Add AI writing assistance with ML Kit integration
This commit is contained in:
Vitor Pamplona
2026-04-08 13:28:08 -04:00
committed by GitHub
17 changed files with 818 additions and 0 deletions
+5
View File
@@ -338,6 +338,11 @@ dependencies {
// Google services model the translate text // Google services model the translate text
playImplementation libs.google.mlkit.translate 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 // PushNotifications
playImplementation platform(libs.firebase.bom) playImplementation platform(libs.firebase.bom)
playImplementation libs.firebase.messaging playImplementation libs.firebase.messaging
@@ -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() {}
}
@@ -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()
}
@@ -38,6 +38,7 @@ data class UiSettings(
val dontAskForNotificationPermissions: Boolean = false, val dontAskForNotificationPermissions: Boolean = false,
val featureSet: FeatureSetType = FeatureSetType.SIMPLIFIED, val featureSet: FeatureSetType = FeatureSetType.SIMPLIFIED,
val gallerySet: ProfileGalleryType = ProfileGalleryType.CLASSIC, val gallerySet: ProfileGalleryType = ProfileGalleryType.CLASSIC,
val automaticallyProposeAiImprovements: BooleanType = BooleanType.ALWAYS,
) )
enum class ThemeType( enum class ThemeType(
@@ -38,6 +38,7 @@ class UiSettingsFlow(
val dontAskForNotificationPermissions: MutableStateFlow<Boolean> = MutableStateFlow(false), val dontAskForNotificationPermissions: MutableStateFlow<Boolean> = MutableStateFlow(false),
val featureSet: MutableStateFlow<FeatureSetType> = MutableStateFlow(FeatureSetType.SIMPLIFIED), val featureSet: MutableStateFlow<FeatureSetType> = MutableStateFlow(FeatureSetType.SIMPLIFIED),
val gallerySet: MutableStateFlow<ProfileGalleryType> = MutableStateFlow(ProfileGalleryType.CLASSIC), val gallerySet: MutableStateFlow<ProfileGalleryType> = MutableStateFlow(ProfileGalleryType.CLASSIC),
val automaticallyProposeAiImprovements: MutableStateFlow<BooleanType> = MutableStateFlow(BooleanType.ALWAYS),
) { ) {
val listOfFlows: List<Flow<Any?>> = val listOfFlows: List<Flow<Any?>> =
listOf<Flow<Any?>>( listOf<Flow<Any?>>(
@@ -52,6 +53,7 @@ class UiSettingsFlow(
dontAskForNotificationPermissions, dontAskForNotificationPermissions,
featureSet, featureSet,
gallerySet, gallerySet,
automaticallyProposeAiImprovements,
) )
// emits at every change in any of the propertyes. // emits at every change in any of the propertyes.
@@ -69,6 +71,7 @@ class UiSettingsFlow(
flows[8] as Boolean, flows[8] as Boolean,
flows[9] as FeatureSetType, flows[9] as FeatureSetType,
flows[10] as ProfileGalleryType, flows[10] as ProfileGalleryType,
flows[11] as BooleanType,
) )
} }
@@ -85,6 +88,7 @@ class UiSettingsFlow(
dontAskForNotificationPermissions.value, dontAskForNotificationPermissions.value,
featureSet.value, featureSet.value,
gallerySet.value, gallerySet.value,
automaticallyProposeAiImprovements.value,
) )
fun update(torSettings: UiSettings): Boolean { fun update(torSettings: UiSettings): Boolean {
@@ -134,6 +138,10 @@ class UiSettingsFlow(
gallerySet.tryEmit(torSettings.gallerySet) gallerySet.tryEmit(torSettings.gallerySet)
any = true any = true
} }
if (automaticallyProposeAiImprovements.value != torSettings.automaticallyProposeAiImprovements) {
automaticallyProposeAiImprovements.tryEmit(torSettings.automaticallyProposeAiImprovements)
any = true
}
return any return any
} }
@@ -164,6 +172,7 @@ class UiSettingsFlow(
MutableStateFlow(uiSettings.dontAskForNotificationPermissions), MutableStateFlow(uiSettings.dontAskForNotificationPermissions),
MutableStateFlow(uiSettings.featureSet), MutableStateFlow(uiSettings.featureSet),
MutableStateFlow(uiSettings.gallerySet), MutableStateFlow(uiSettings.gallerySet),
MutableStateFlow(uiSettings.automaticallyProposeAiImprovements),
) )
} }
} }
@@ -102,6 +102,7 @@ class UiSharedPreferences(
val UI_DONT_ASK_FOR_NOTIFICATION_PERMISSIONS = booleanPreferencesKey("ui.dont_ask_for_notification_permissions") val UI_DONT_ASK_FOR_NOTIFICATION_PERMISSIONS = booleanPreferencesKey("ui.dont_ask_for_notification_permissions")
val UI_FEATURE_SET = stringPreferencesKey("ui.feature_set") val UI_FEATURE_SET = stringPreferencesKey("ui.feature_set")
val UI_GALLERY_SET = stringPreferencesKey("ui.gallery_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? = suspend fun uiPreferences(context: Context): UiSettings? =
try { try {
@@ -120,6 +121,7 @@ class UiSharedPreferences(
dontAskForNotificationPermissions = preferences[UI_DONT_ASK_FOR_NOTIFICATION_PERMISSIONS] ?: false, dontAskForNotificationPermissions = preferences[UI_DONT_ASK_FOR_NOTIFICATION_PERMISSIONS] ?: false,
featureSet = preferences[UI_FEATURE_SET]?.let { FeatureSetType.valueOf(it) } ?: FeatureSetType.SIMPLIFIED, featureSet = preferences[UI_FEATURE_SET]?.let { FeatureSetType.valueOf(it) } ?: FeatureSetType.SIMPLIFIED,
gallerySet = preferences[UI_GALLERY_SET]?.let { ProfileGalleryType.valueOf(it) } ?: ProfileGalleryType.CLASSIC, 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) { } catch (e: Exception) {
if (e is CancellationException) throw e if (e is CancellationException) throw e
@@ -155,6 +157,7 @@ class UiSharedPreferences(
preferences[UI_DONT_ASK_FOR_NOTIFICATION_PERMISSIONS] = sharedSettings.dontAskForNotificationPermissions preferences[UI_DONT_ASK_FOR_NOTIFICATION_PERMISSIONS] = sharedSettings.dontAskForNotificationPermissions
preferences[UI_FEATURE_SET] = sharedSettings.featureSet.name preferences[UI_FEATURE_SET] = sharedSettings.featureSet.name
preferences[UI_GALLERY_SET] = sharedSettings.gallerySet.name preferences[UI_GALLERY_SET] = sharedSettings.gallerySet.name
preferences[UI_PROPOSE_AI_IMPROVEMENTS] = sharedSettings.automaticallyProposeAiImprovements.name
} }
} catch (e: Exception) { } catch (e: Exception) {
if (e is CancellationException) throw e if (e is CancellationException) throw e
@@ -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() {}
}
@@ -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,
)
@@ -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
},
)
}
}
@@ -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<WritingTone, WritingResult>,
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<WritingTone>,
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)
}
@@ -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.navigation.topbars.PostingTopBar
import com.vitorpamplona.amethyst.ui.note.BaseUserPicture import com.vitorpamplona.amethyst.ui.note.BaseUserPicture
import com.vitorpamplona.amethyst.ui.note.NoteCompose 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.ContentSensitivityExplainer
import com.vitorpamplona.amethyst.ui.note.creators.contentWarning.MarkAsSensitiveButton import com.vitorpamplona.amethyst.ui.note.creators.contentWarning.MarkAsSensitiveButton
import com.vitorpamplona.amethyst.ui.note.creators.emojiSuggestions.ShowEmojiSuggestionList import com.vitorpamplona.amethyst.ui.note.creators.emojiSuggestions.ShowEmojiSuggestionList
@@ -144,6 +145,10 @@ fun ShortNotePostScreen(
val context = LocalContext.current val context = LocalContext.current
val activity = context.getActivity() val activity = context.getActivity()
LaunchedEffect(Unit) {
postViewModel.initWritingAssistant(context)
}
LaunchedEffect(postViewModel, accountViewModel) { LaunchedEffect(postViewModel, accountViewModel) {
val baseReplyTo = baseReplyToId?.let { accountViewModel.getNoteIfExists(it) } val baseReplyTo = baseReplyToId?.let { accountViewModel.getNoteIfExists(it) }
val quote = quoteId?.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) BottomRowActions(postViewModel)
} }
} }
@@ -41,9 +41,16 @@ import com.vitorpamplona.amethyst.commons.compose.replaceCurrentWord
import com.vitorpamplona.amethyst.commons.compose.setTextAndPlaceCursorAtBeginning import com.vitorpamplona.amethyst.commons.compose.setTextAndPlaceCursorAtBeginning
import com.vitorpamplona.amethyst.commons.model.nip30CustomEmojis.EmojiPackState.EmojiMedia import com.vitorpamplona.amethyst.commons.model.nip30CustomEmojis.EmojiPackState.EmojiMedia
import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.BooleanType
import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User 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.location.LocationState
import com.vitorpamplona.amethyst.service.uploads.CompressorQuality import com.vitorpamplona.amethyst.service.uploads.CompressorQuality
import com.vitorpamplona.amethyst.service.uploads.MediaCompressor 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.RandomInstance
import com.vitorpamplona.quartz.utils.TimeUtils import com.vitorpamplona.quartz.utils.TimeUtils
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers 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.StateFlow
import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -291,6 +304,96 @@ open class ShortNotePostViewModel :
// Anonymous Reply // Anonymous Reply
var wantsAnonymousPost by mutableStateOf(false) var wantsAnonymousPost by mutableStateOf(false)
// AI Writing Help for testing
private val useMockAi = false
var aiResults by mutableStateOf<Map<WritingTone, WritingResult>>(emptyMap())
var aiSelectedResult by mutableStateOf<WritingResult?>(null)
var aiStatus by mutableStateOf<WritingAssistantStatus>(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 lnAddress(): String? = account.userProfile().lnAddress()
fun hasLnAddress(): Boolean = account.userProfile().lnAddress() != null fun hasLnAddress(): Boolean = account.userProfile().lnAddress() != null
@@ -1131,6 +1234,7 @@ open class ShortNotePostViewModel :
emojiSuggestions?.processCurrentWord(lastWord) emojiSuggestions?.processCurrentWord(lastWord)
} }
precomputeAiResults()
draftTag.newVersion() draftTag.newVersion()
} }
@@ -1372,6 +1476,8 @@ open class ShortNotePostViewModel :
override fun onCleared() { override fun onCleared() {
super.onCleared() super.onCleared()
writingAssistant?.close()
writingAssistant = null
Log.d("Init") { "OnCleared: ${this.javaClass.simpleName}" } Log.d("Init") { "OnCleared: ${this.javaClass.simpleName}" }
} }
@@ -121,6 +121,7 @@ fun SettingsScreen(sharedPrefs: UiSettingsFlow) {
ImmersiveScrollingChoice(sharedPrefs) ImmersiveScrollingChoice(sharedPrefs)
FeatureSetChoice(sharedPrefs) FeatureSetChoice(sharedPrefs)
GalleryChoice(sharedPrefs) GalleryChoice(sharedPrefs)
AiWritingHelpChoice(sharedPrefs)
PushNotificationSettingsRow(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 @Composable
fun SettingsRow( fun SettingsRow(
name: Int, name: Int,
+15
View File
@@ -2199,4 +2199,19 @@
<string name="relay_members_removed">%1$d members removed from relay</string> <string name="relay_members_removed">%1$d members removed from relay</string>
<string name="relay_join_request">Relay join request</string> <string name="relay_join_request">Relay join request</string>
<string name="relay_leave_request">Relay leave request</string> <string name="relay_leave_request">Relay leave request</string>
<string name="ai_writing_help">AI Writing Help</string>
<string name="ai_writing_setting_title">Propose text improvements</string>
<string name="ai_writing_setting_description">Uses an on-device AI model to propose text corrections and tone changes.</string>
<string name="ai_writing_use_this">Use This</string>
<string name="ai_writing_dismiss">Dismiss</string>
<string name="ai_tone_correct">Correct</string>
<string name="ai_tone_rephrase">Rephrase</string>
<string name="ai_tone_shorter">Shorter</string>
<string name="ai_tone_elaborate">Elaborate</string>
<string name="ai_tone_friendly">Friendly</string>
<string name="ai_tone_professional">Professional</string>
<string name="ai_tone_more_direct">More Direct</string>
<string name="ai_tone_punchy">Punchy</string>
<string name="ai_tone_emojify">+ Emoji</string>
</resources> </resources>
@@ -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<Long, Rewriter>()
private var proofreaders = mutableMapOf<Int, Proofreader>()
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
}
}
}
@@ -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)
}
+6
View File
@@ -30,6 +30,9 @@ kotlin = "2.3.20"
kotlinxCollectionsImmutable = "0.4.0" kotlinxCollectionsImmutable = "0.4.0"
kotlinxCoroutinesCore = "1.10.2" kotlinxCoroutinesCore = "1.10.2"
kotlinxSerialization = "1.10.0" kotlinxSerialization = "1.10.0"
genaiProofreading = "1.0.0-beta1"
genaiPrompt = "1.0.0-beta2"
genaiRewriting = "1.0.0-beta1"
languageId = "17.0.6" languageId = "17.0.6"
lifecycleRuntimeKtx = "2.10.0" lifecycleRuntimeKtx = "2.10.0"
lightcompressor-enhanced = "1.8.1" 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 = { 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 = { 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" } 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-language-id = { group = "com.google.mlkit", name = "language-id", version.ref = "languageId" }
google-mlkit-translate = { group = "com.google.mlkit", name = "translate", version.ref = "translate" } 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" } jackson-module-kotlin = { group = "com.fasterxml.jackson.module", name = "jackson-module-kotlin", version.ref = "jacksonModuleKotlin" }