Skip to content

Commit 60dfe1d

Browse files
authored
feat: draft replies on a foreground Compose screen for AICore support (#2274)
Closes #2273
1 parent 9952c2e commit 60dfe1d

18 files changed

Lines changed: 370 additions & 408 deletions

app/src/main/AndroidManifest.xml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,13 @@
4444
</intent-filter>
4545
</activity>
4646

47+
<activity
48+
android:name=".screens.replydrafter.ReplyDrafterActivity"
49+
android:excludeFromRecents="true"
50+
android:exported="false"
51+
android:taskAffinity=""
52+
android:theme="@style/Theme.Switchify" />
53+
4754
<service
4855
android:name=".service.core.SwitchifyAccessibilityService"
4956
android:directBootAware="true"
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
package com.enaboapps.switchify.screens.replydrafter
2+
3+
import android.os.Bundle
4+
import android.widget.Toast
5+
import androidx.activity.ComponentActivity
6+
import androidx.activity.compose.setContent
7+
import androidx.compose.runtime.getValue
8+
import androidx.compose.runtime.livedata.observeAsState
9+
import androidx.compose.ui.platform.LocalContext
10+
import androidx.lifecycle.viewmodel.compose.viewModel
11+
import com.enaboapps.switchify.R
12+
import com.enaboapps.switchify.activities.ui.theme.SwitchifyTheme
13+
14+
/**
15+
* Visible, foreground screen that drafts replies. Being the top foreground
16+
* activity is what lets AICore (Gemini Nano) run inference — it refuses when
17+
* the caller is in the background.
18+
*/
19+
class ReplyDrafterActivity : ComponentActivity() {
20+
override fun onCreate(savedInstanceState: Bundle?) {
21+
super.onCreate(savedInstanceState)
22+
setContent {
23+
val context = LocalContext.current
24+
val viewModel: ReplyDrafterViewModel = viewModel { ReplyDrafterViewModel(context) }
25+
val state by viewModel.uiState.observeAsState(ReplyDrafterUiState.Loading)
26+
27+
SwitchifyTheme {
28+
ReplyDrafterScreen(
29+
state = state,
30+
onSelect = { reply ->
31+
viewModel.copyToClipboard(reply)
32+
Toast.makeText(
33+
this@ReplyDrafterActivity,
34+
R.string.reply_drafter_copied,
35+
Toast.LENGTH_SHORT
36+
).show()
37+
finish()
38+
},
39+
onRetry = { viewModel.draft() },
40+
onClose = { finish() }
41+
)
42+
}
43+
}
44+
}
45+
}
Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
package com.enaboapps.switchify.screens.replydrafter
2+
3+
import androidx.compose.foundation.layout.Arrangement
4+
import androidx.compose.foundation.layout.Box
5+
import androidx.compose.foundation.layout.Column
6+
import androidx.compose.foundation.layout.Spacer
7+
import androidx.compose.foundation.layout.fillMaxSize
8+
import androidx.compose.foundation.layout.fillMaxWidth
9+
import androidx.compose.foundation.layout.height
10+
import androidx.compose.foundation.layout.padding
11+
import androidx.compose.foundation.layout.systemBarsPadding
12+
import androidx.compose.foundation.rememberScrollState
13+
import androidx.compose.foundation.verticalScroll
14+
import androidx.compose.material3.Card
15+
import androidx.compose.material3.CircularProgressIndicator
16+
import androidx.compose.material3.MaterialTheme
17+
import androidx.compose.material3.Surface
18+
import androidx.compose.material3.Text
19+
import androidx.compose.runtime.Composable
20+
import androidx.compose.ui.Alignment
21+
import androidx.compose.ui.Modifier
22+
import androidx.compose.ui.res.stringResource
23+
import androidx.compose.ui.text.font.FontWeight
24+
import androidx.compose.ui.text.style.TextAlign
25+
import com.enaboapps.switchify.R
26+
import com.enaboapps.switchify.components.ActionButton
27+
import com.enaboapps.switchify.components.ActionButtonType
28+
import com.enaboapps.switchify.theme.Dimens
29+
30+
@Composable
31+
fun ReplyDrafterScreen(
32+
state: ReplyDrafterUiState,
33+
onSelect: (String) -> Unit,
34+
onRetry: () -> Unit,
35+
onClose: () -> Unit
36+
) {
37+
Surface(
38+
modifier = Modifier.fillMaxSize(),
39+
color = MaterialTheme.colorScheme.background
40+
) {
41+
Column(
42+
modifier = Modifier
43+
.fillMaxSize()
44+
.systemBarsPadding()
45+
.padding(Dimens.spaceL)
46+
) {
47+
Text(
48+
text = stringResource(R.string.reply_drafter_suggestions_title),
49+
style = MaterialTheme.typography.headlineSmall,
50+
fontWeight = FontWeight.SemiBold
51+
)
52+
Spacer(modifier = Modifier.height(Dimens.spaceL))
53+
54+
Box(
55+
modifier = Modifier
56+
.weight(1f)
57+
.fillMaxWidth()
58+
) {
59+
when (state) {
60+
is ReplyDrafterUiState.Loading -> LoadingContent()
61+
62+
is ReplyDrafterUiState.Suggestions ->
63+
SuggestionsContent(state.replies, onSelect)
64+
65+
is ReplyDrafterUiState.Failed ->
66+
FailedContent(state, onRetry)
67+
}
68+
}
69+
70+
Spacer(modifier = Modifier.height(Dimens.spaceM))
71+
ActionButton(
72+
textResId = R.string.reply_drafter_close,
73+
onClick = onClose,
74+
modifier = Modifier.fillMaxWidth(),
75+
type = ActionButtonType.SECONDARY,
76+
applyPadding = false
77+
)
78+
}
79+
}
80+
}
81+
82+
@Composable
83+
private fun LoadingContent() {
84+
Column(
85+
modifier = Modifier.fillMaxSize(),
86+
horizontalAlignment = Alignment.CenterHorizontally,
87+
verticalArrangement = Arrangement.Center
88+
) {
89+
CircularProgressIndicator()
90+
Spacer(modifier = Modifier.height(Dimens.spaceM))
91+
Text(
92+
text = stringResource(R.string.reply_drafter_processing),
93+
style = MaterialTheme.typography.bodyLarge
94+
)
95+
}
96+
}
97+
98+
@Composable
99+
private fun SuggestionsContent(replies: List<String>, onSelect: (String) -> Unit) {
100+
Column(
101+
modifier = Modifier
102+
.fillMaxSize()
103+
.verticalScroll(rememberScrollState()),
104+
verticalArrangement = Arrangement.spacedBy(Dimens.spaceS)
105+
) {
106+
Text(
107+
text = stringResource(R.string.reply_drafter_pick_hint),
108+
style = MaterialTheme.typography.bodyMedium,
109+
color = MaterialTheme.colorScheme.onSurfaceVariant
110+
)
111+
replies.forEach { reply ->
112+
ReplyCard(reply = reply, onClick = { onSelect(reply) })
113+
}
114+
}
115+
}
116+
117+
@Composable
118+
private fun ReplyCard(reply: String, onClick: () -> Unit) {
119+
Card(
120+
onClick = onClick,
121+
modifier = Modifier.fillMaxWidth()
122+
) {
123+
Text(
124+
text = reply,
125+
modifier = Modifier.padding(Dimens.spaceM),
126+
style = MaterialTheme.typography.bodyLarge
127+
)
128+
}
129+
}
130+
131+
@Composable
132+
private fun FailedContent(state: ReplyDrafterUiState.Failed, onRetry: () -> Unit) {
133+
Column(
134+
modifier = Modifier.fillMaxSize(),
135+
horizontalAlignment = Alignment.CenterHorizontally,
136+
verticalArrangement = Arrangement.Center
137+
) {
138+
Text(
139+
text = stringResource(state.messageRes),
140+
style = MaterialTheme.typography.bodyLarge,
141+
textAlign = TextAlign.Center
142+
)
143+
if (state.canRetry) {
144+
Spacer(modifier = Modifier.height(Dimens.spaceM))
145+
ActionButton(
146+
textResId = R.string.reply_drafter_retry,
147+
onClick = onRetry
148+
)
149+
}
150+
}
151+
}
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
package com.enaboapps.switchify.screens.replydrafter
2+
3+
import android.content.ClipData
4+
import android.content.ClipboardManager
5+
import android.content.Context
6+
import android.graphics.Bitmap
7+
import android.util.Log
8+
import androidx.lifecycle.LiveData
9+
import androidx.lifecycle.MutableLiveData
10+
import androidx.lifecycle.ViewModel
11+
import androidx.lifecycle.viewModelScope
12+
import com.enaboapps.switchify.R
13+
import com.enaboapps.switchify.service.llm.AiCoreManager
14+
import com.enaboapps.switchify.service.llm.LlmManager
15+
import com.enaboapps.switchify.service.llm.ReplyDrafterScreenshotHolder
16+
import com.enaboapps.switchify.service.llm.model.ModelManager
17+
import kotlinx.coroutines.Dispatchers
18+
import kotlinx.coroutines.launch
19+
import kotlinx.coroutines.withContext
20+
21+
sealed interface ReplyDrafterUiState {
22+
data object Loading : ReplyDrafterUiState
23+
data class Suggestions(val replies: List<String>) : ReplyDrafterUiState
24+
data class Failed(val messageRes: Int, val canRetry: Boolean) : ReplyDrafterUiState
25+
}
26+
27+
class ReplyDrafterViewModel(context: Context) : ViewModel() {
28+
private val appContext = context.applicationContext
29+
private val bitmap: Bitmap? = ReplyDrafterScreenshotHolder.consume()
30+
31+
private val _uiState = MutableLiveData<ReplyDrafterUiState>(ReplyDrafterUiState.Loading)
32+
val uiState: LiveData<ReplyDrafterUiState> = _uiState
33+
34+
init {
35+
draft()
36+
}
37+
38+
fun draft() {
39+
val image = bitmap
40+
if (image == null) {
41+
_uiState.value =
42+
ReplyDrafterUiState.Failed(R.string.reply_drafter_llm_failed, canRetry = false)
43+
return
44+
}
45+
_uiState.value = ReplyDrafterUiState.Loading
46+
viewModelScope.launch {
47+
_uiState.value = withContext(Dispatchers.Default) { runInference(image) }
48+
}
49+
}
50+
51+
// AICore (Gemini Nano) when the device supports it, otherwise MediaPipe with
52+
// the downloaded Gemma model.
53+
private suspend fun runInference(image: Bitmap): ReplyDrafterUiState {
54+
return try {
55+
val suggestions =
56+
if (AiCoreManager.availability() == AiCoreManager.Availability.AVAILABLE) {
57+
AiCoreManager.generateReplySuggestions(image)
58+
} else {
59+
val modelPath = ModelManager(appContext).getModelFileIfReady()?.absolutePath
60+
?: return ReplyDrafterUiState.Failed(
61+
R.string.reply_drafter_model_not_ready,
62+
canRetry = false
63+
)
64+
generateWithMediaPipe(image, modelPath)
65+
}
66+
if (suggestions.isEmpty()) {
67+
ReplyDrafterUiState.Failed(R.string.reply_drafter_no_suggestions, canRetry = true)
68+
} else {
69+
ReplyDrafterUiState.Suggestions(suggestions)
70+
}
71+
} catch (e: Exception) {
72+
Log.e(TAG, "Reply drafting failed", e)
73+
ReplyDrafterUiState.Failed(R.string.reply_drafter_llm_failed, canRetry = true)
74+
}
75+
}
76+
77+
// LlmManager.generateReplySuggestions is callback-based but synchronous, so
78+
// one of the callbacks has fired by the time it returns.
79+
private fun generateWithMediaPipe(image: Bitmap, modelPath: String): List<String> {
80+
var result: List<String>? = null
81+
var error: String? = null
82+
LlmManager.generateReplySuggestions(
83+
context = appContext,
84+
bitmap = image,
85+
modelPath = modelPath,
86+
onResult = { result = it },
87+
onError = { error = it }
88+
)
89+
return result ?: throw IllegalStateException(error ?: "Reply drafting failed")
90+
}
91+
92+
fun copyToClipboard(reply: String) {
93+
val clipboard =
94+
appContext.getSystemService(Context.CLIPBOARD_SERVICE) as? ClipboardManager
95+
clipboard?.setPrimaryClip(ClipData.newPlainText("reply", reply))
96+
}
97+
98+
companion object {
99+
private const val TAG = "ReplyDrafterViewModel"
100+
}
101+
}

0 commit comments

Comments
 (0)