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
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
@@ -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 featureSet: FeatureSetType = FeatureSetType.SIMPLIFIED,
val gallerySet: ProfileGalleryType = ProfileGalleryType.CLASSIC,
val automaticallyProposeAiImprovements: BooleanType = BooleanType.ALWAYS,
)
enum class ThemeType(
@@ -38,6 +38,7 @@ class UiSettingsFlow(
val dontAskForNotificationPermissions: MutableStateFlow<Boolean> = MutableStateFlow(false),
val featureSet: MutableStateFlow<FeatureSetType> = MutableStateFlow(FeatureSetType.SIMPLIFIED),
val gallerySet: MutableStateFlow<ProfileGalleryType> = MutableStateFlow(ProfileGalleryType.CLASSIC),
val automaticallyProposeAiImprovements: MutableStateFlow<BooleanType> = MutableStateFlow(BooleanType.ALWAYS),
) {
val listOfFlows: List<Flow<Any?>> =
listOf<Flow<Any?>>(
@@ -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),
)
}
}
@@ -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
@@ -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.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)
}
}
@@ -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<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 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}" }
}
@@ -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,
+15
View File
@@ -2199,4 +2199,19 @@
<string name="relay_members_removed">%1$d members removed from relay</string>
<string name="relay_join_request">Relay join 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>
@@ -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"
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" }