feat: add AI writing help to new post screen

Add on-device AI writing assistance using ML Kit GenAI APIs (Rewriting,
Proofreading) powered by Gemini Nano via Android AICore. Users can tap
the sparkle button in the compose toolbar to access tone chips (Correct,
Rephrase, Shorter, Elaborate, Friendly, Professional, More Direct,
Punchy, + Emoji) that transform their post text. Results appear in a
card with Use/Dismiss actions.

- Play flavor: MLKitWritingAssistant using on-device Gemini Nano
- F-Droid flavor: NoOpWritingAssistant stub (returns unavailable)
- WritingAssistant interface for future DVM/NIP90 integration
- AI button hidden on unsupported devices

Also fixes pre-existing exhaustive when expression in
DecryptAndIndexProcessor for GroupEventResult.Duplicate.

https://claude.ai/code/session_01RbCYGrbbapRMike8WQy41F
This commit is contained in:
Claude
2026-04-08 12:47:23 +00:00
parent 5e93ff0532
commit 7ae536e80d
14 changed files with 641 additions and 1 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()
}
@@ -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,
)
@@ -73,7 +73,8 @@ class MarmotGroupEventsEoseManager(
metadata metadata
?.relays ?.relays
?.mapNotNull { ?.mapNotNull {
com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer.normalizeOrNull(it) com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
.normalizeOrNull(it)
}?.toSet() }?.toSet()
val relaysForGroup = if (!groupRelays.isNullOrEmpty()) groupRelays else fallbackRelays val relaysForGroup = if (!groupRelays.isNullOrEmpty()) groupRelays else fallbackRelays
for (relay in relaysForGroup) { for (relay in relaysForGroup) {
@@ -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,176 @@
/*
* 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.Box
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.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.CircularProgressIndicator
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.Alignment
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,
isProcessing: Boolean,
result: WritingResult?,
onToneSelected: (WritingTone) -> Unit,
onApply: () -> Unit,
onDismiss: () -> Unit,
) {
AnimatedVisibility(
visible = isVisible,
enter = expandVertically(),
exit = shrinkVertically(),
) {
Column(
modifier =
Modifier
.fillMaxWidth()
.padding(horizontal = 8.dp),
) {
if (isProcessing) {
Box(
modifier =
Modifier
.fillMaxWidth()
.padding(vertical = 12.dp),
contentAlignment = Alignment.Center,
) {
CircularProgressIndicator(modifier = Modifier.size(24.dp))
}
}
result?.let {
AiResultCard(
result = it,
onApply = onApply,
onDismiss = onDismiss,
)
}
ToneChipRow(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(onToneSelected: (WritingTone) -> Unit) {
val scrollState = rememberScrollState()
Row(
modifier =
Modifier
.horizontalScroll(scrollState)
.fillMaxWidth()
.padding(vertical = 4.dp),
horizontalArrangement = Arrangement.spacedBy(6.dp),
) {
WritingTone.entries.forEach { tone ->
FilterChip(
selected = false,
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)
}
@@ -496,6 +496,10 @@ class GroupEventHandler(
Log.d("GroupEventHandler", "Commit pending for group ${result.groupId}, epoch=${result.epoch}") Log.d("GroupEventHandler", "Commit pending for group ${result.groupId}, epoch=${result.epoch}")
} }
is GroupEventResult.Duplicate -> {
Log.d("GroupEventHandler") { "Duplicate event for group ${result.groupId}" }
}
is GroupEventResult.Error -> { is GroupEventResult.Error -> {
Log.w("GroupEventHandler") { "Error processing GroupEvent: ${result.message}" } Log.w("GroupEventHandler") { "Error processing GroupEvent: ${result.message}" }
} }
@@ -65,6 +65,7 @@ import androidx.core.net.toUri
import androidx.core.util.Consumer import androidx.core.util.Consumer
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.service.ai.WritingAssistantStatus
import com.vitorpamplona.amethyst.ui.actions.StrippingFailureDialog import com.vitorpamplona.amethyst.ui.actions.StrippingFailureDialog
import com.vitorpamplona.amethyst.ui.actions.mediaServers.FileServerSelectionRow import com.vitorpamplona.amethyst.ui.actions.mediaServers.FileServerSelectionRow
import com.vitorpamplona.amethyst.ui.actions.uploads.MAX_VOICE_RECORD_SECONDS import com.vitorpamplona.amethyst.ui.actions.uploads.MAX_VOICE_RECORD_SECONDS
@@ -82,6 +83,8 @@ 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.AiWritingHelpButton
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 +147,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 +563,15 @@ private fun NewPostScreenBody(
) )
} }
AiWritingHelpPanel(
isVisible = postViewModel.wantsAiHelp,
isProcessing = postViewModel.isAiProcessing,
result = postViewModel.aiResult,
onToneSelected = postViewModel::requestAiTransform,
onApply = postViewModel::applyAiResult,
onDismiss = postViewModel::dismissAiResult,
)
BottomRowActions(postViewModel) BottomRowActions(postViewModel)
} }
} }
@@ -651,6 +667,12 @@ private fun BottomRowActions(postViewModel: ShortNotePostViewModel) {
postViewModel.wantsInvoice = !postViewModel.wantsInvoice postViewModel.wantsInvoice = !postViewModel.wantsInvoice
} }
} }
if (postViewModel.aiStatus is WritingAssistantStatus.Available) {
AiWritingHelpButton(postViewModel.wantsAiHelp) {
postViewModel.wantsAiHelp = !postViewModel.wantsAiHelp
}
}
} }
} }
@@ -44,6 +44,11 @@ import com.vitorpamplona.amethyst.model.Account
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.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
@@ -291,6 +296,53 @@ open class ShortNotePostViewModel :
// Anonymous Reply // Anonymous Reply
var wantsAnonymousPost by mutableStateOf(false) var wantsAnonymousPost by mutableStateOf(false)
// AI Writing Help
var wantsAiHelp by mutableStateOf(false)
var aiResult by mutableStateOf<WritingResult?>(null)
var aiStatus by mutableStateOf<WritingAssistantStatus>(WritingAssistantStatus.Unavailable)
var isAiProcessing by mutableStateOf(false)
private var writingAssistant: WritingAssistant? = null
fun initWritingAssistant(context: android.content.Context) {
if (writingAssistant == null) {
writingAssistant = WritingAssistantFactory.create(context)
viewModelScope.launch(Dispatchers.IO) {
aiStatus = writingAssistant?.checkAvailability() ?: WritingAssistantStatus.Unavailable
}
}
}
fun requestAiTransform(tone: WritingTone) {
val text = message.text.toString()
if (text.isBlank() || isAiProcessing) return
isAiProcessing = true
aiResult = null
viewModelScope.launch(Dispatchers.IO) {
try {
val result = writingAssistant?.transform(text, tone)
aiResult = result
} catch (e: Exception) {
if (e is kotlinx.coroutines.CancellationException) throw e
aiResult = null
} finally {
isAiProcessing = false
}
}
}
fun applyAiResult() {
aiResult?.let {
message.setTextAndPlaceCursorAtEnd(it.transformedText)
aiResult = null
draftTag.newVersion()
}
}
fun dismissAiResult() {
aiResult = 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
@@ -1372,6 +1424,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}" }
} }
+13
View File
@@ -2199,4 +2199,17 @@
<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_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,156 @@
/*
* 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.mlkit.genai.common.FeatureStatus
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 kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
class MLKitWritingAssistant(
private val context: Context,
) : WritingAssistant {
private var rewriters = mutableMapOf<Int, Rewriter>()
private var proofreader =
Proofreading.getClient(
ProofreaderOptions
.builder(context)
.setInputType(ProofreaderOptions.InputType.KEYBOARD)
.setLanguage(ProofreaderOptions.Language.ENGLISH)
.build(),
)
private fun getRewriter(
@RewriterOptions.OutputType outputType: Int,
): Rewriter =
rewriters.getOrPut(outputType) {
Rewriting.getClient(
RewriterOptions
.builder(context)
.setOutputType(outputType)
.setLanguage(RewriterOptions.Language.ENGLISH)
.build(),
)
}
override suspend fun checkAvailability(): WritingAssistantStatus =
withContext(Dispatchers.IO) {
try {
val status = getRewriter(RewriterOptions.OutputType.REPHRASE).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 transformedText =
when (tone) {
WritingTone.CORRECT -> {
proofread(text)
}
WritingTone.REPHRASE -> {
rewrite(text, RewriterOptions.OutputType.REPHRASE)
}
WritingTone.SHORTER -> {
rewrite(text, RewriterOptions.OutputType.SHORTEN)
}
WritingTone.ELABORATE -> {
rewrite(text, RewriterOptions.OutputType.ELABORATE)
}
WritingTone.FRIENDLY -> {
rewrite(text, RewriterOptions.OutputType.FRIENDLY)
}
WritingTone.PROFESSIONAL -> {
rewrite(text, RewriterOptions.OutputType.PROFESSIONAL)
}
WritingTone.EMOJIFY -> {
rewrite(text, RewriterOptions.OutputType.EMOJIFY)
}
WritingTone.MORE_DIRECT -> {
rewrite(text, RewriterOptions.OutputType.PROFESSIONAL)
}
WritingTone.PUNCHY -> {
rewrite(text, RewriterOptions.OutputType.SHORTEN)
}
}
return WritingResult(
originalText = text,
transformedText = transformedText,
tone = tone,
)
}
private suspend fun rewrite(
text: String,
@RewriterOptions.OutputType outputType: Int,
): String =
withContext(Dispatchers.IO) {
val rewriter = getRewriter(outputType)
val request = RewritingRequest.builder(text).build()
val result = rewriter.runInference(request).get()
result.results.firstOrNull()?.text ?: text
}
private suspend fun proofread(text: String): String =
withContext(Dispatchers.IO) {
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()
proofreader.close()
}
}
@@ -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" }