Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/play-release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ jobs:
SUPABASE_URL: ${{ secrets.SUPABASE_URL }}
SUPABASE_ANON_KEY: ${{ secrets.SUPABASE_ANON_KEY }}
GOOGLE_WEB_CLIENT_ID: ${{ secrets.GOOGLE_WEB_CLIENT_ID }}
REPLY_DRAFTER_MODEL_URL: ${{ secrets.REPLY_DRAFTER_MODEL_URL }}
run: ./gradlew bundleRelease --no-daemon

# Single upload that targets one or more Play tracks. Prereleases
Expand Down
7 changes: 7 additions & 0 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,11 @@ android {
"GOOGLE_WEB_CLIENT_ID",
"\"${configValue("GOOGLE_WEB_CLIENT_ID", "google.webClientId")}\""
)
buildConfigField(
"String",
"REPLY_DRAFTER_MODEL_URL",
"\"${configValue("REPLY_DRAFTER_MODEL_URL", "replyDrafter.modelUrl")}\""
)
}

// CI-only release signing: activates when UPLOAD_KEYSTORE_PATH points at
Expand Down Expand Up @@ -165,6 +170,8 @@ dependencies {
implementation(libs.androidx.camera.view)
implementation(libs.accompanist.permissions)
implementation(libs.mediapipe.tasks.vision)
implementation(libs.mediapipe.tasks.genai)
implementation(libs.androidx.work.runtime)
implementation(libs.room.runtime)
implementation(libs.room.ktx)
ksp(libs.room.compiler)
Expand Down
8 changes: 8 additions & 0 deletions app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@
tools:ignore="ProtectedPermissions" /> <!-- Required for recent apps -->
<uses-permission android:name="com.google.android.gms.permission.AD_ID" /> <!-- Required for advertising identifier access -->
<uses-permission android:name="android.permission.WAKE_LOCK" /> <!-- Required for camera service wake lock -->
<uses-permission android:name="android.permission.INTERNET" /> <!-- Required for downloading the Reply Drafter model -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" /> <!-- Required for the model download foreground worker -->

<application
android:name=".SwitchifyApplication"
Expand Down Expand Up @@ -63,6 +65,12 @@
android:exported="false"
android:foregroundServiceType="camera"></service>

<!-- dataSync type for the model download foreground worker -->
<service
android:name="androidx.work.impl.foreground.SystemForegroundService"
android:foregroundServiceType="dataSync"
tools:node="merge" />

</application>

</manifest>
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,10 @@ class PreferenceManager(context: Context) {
const val PREFERENCE_KEY_CAMERA_HEAD_TURN_DOWN_SENSITIVITY =
"camera_head_turn_down_sensitivity"

// Reply Drafter (on-device LLM) Gemma terms acceptance
const val PREFERENCE_KEY_REPLY_DRAFTER_GEMMA_TERMS_ACCEPTED =
"reply_drafter_gemma_terms_accepted"

private const val PREFERENCE_FILE_NAME = "switchify_preferences"

/**
Expand All @@ -90,7 +94,8 @@ class PreferenceManager(context: Context) {
PREFERENCE_KEY_ACCESS_TECHNIQUE,
PREFERENCE_KEY_SETUP_COMPLETE,
PREFERENCE_KEY_REVIEW_LAST_SHOWN,
PREFERENCE_KEY_TELEMETRY_ENABLED
PREFERENCE_KEY_TELEMETRY_ENABLED,
PREFERENCE_KEY_REPLY_DRAFTER_GEMMA_TERMS_ACCEPTED
)
}

Expand Down
8 changes: 8 additions & 0 deletions app/src/main/java/com/enaboapps/switchify/nav/NavGraph.kt
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ import com.enaboapps.switchify.screens.paywall.AppPaywallScreen
import com.enaboapps.switchify.screens.settings.CameraSettingsScreen
import com.enaboapps.switchify.screens.settings.HeadControlSettingsScreen
import com.enaboapps.switchify.screens.settings.SettingsScreen
import com.enaboapps.switchify.screens.settings.replydrafter.GemmaTermsScreen
import com.enaboapps.switchify.screens.settings.replydrafter.ReplyDrafterSettingsScreen
import com.enaboapps.switchify.screens.settings.gestures.ScrollingSettingsScreen
import com.enaboapps.switchify.screens.settings.patterns.GesturePatternsScreen
import com.enaboapps.switchify.screens.settings.scanning.AutoScanSettingsScreen
Expand Down Expand Up @@ -144,6 +146,12 @@ fun NavGraph(navController: NavHostController) {
composable(NavigationRoute.FavouriteApps.name) {
FavouriteAppsScreen(navController)
}
composable(NavigationRoute.ReplyDrafterSettings.name) {
ReplyDrafterSettingsScreen(navController)
}
composable(NavigationRoute.GemmaTerms.name) {
GemmaTermsScreen(navController)
}
composable("${NavigationRoute.SwitchActionSelection.name}/{currentActionId}") {
it.arguments?.getString("currentActionId")?.toIntOrNull()?.let { actionId ->
SwitchActionSelectionScreen(navController, actionId)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,4 +35,6 @@ sealed class NavigationRoute(val name: String) {
data object SwitchActionSelection : NavigationRoute("SwitchActionSelection")
data object LongPressActions : NavigationRoute("LongPressActions")
data object FavouriteApps : NavigationRoute("FavouriteApps")
data object ReplyDrafterSettings : NavigationRoute("ReplyDrafterSettings")
data object GemmaTerms : NavigationRoute("GemmaTerms")
}
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,14 @@ fun GeneralSettingsTab(menuSettingsModel: MenuSettingsModel, navController: NavC
BehaviourSection(navController)
GesturesSettingsSection(navController)
MenuSection(menuSettingsModel, navController)
Section(titleResId = R.string.settings_section_reply_drafter) {
NavRouteLink(
titleResId = R.string.settings_title_reply_drafter,
summaryResId = R.string.settings_summary_reply_drafter,
navController = navController,
route = NavigationRoute.ReplyDrafterSettings.name
)
}
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
package com.enaboapps.switchify.screens.settings.replydrafter

import android.content.Context
import android.content.Intent
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.core.net.toUri
import androidx.navigation.NavController
import com.enaboapps.switchify.R
import com.enaboapps.switchify.backend.preferences.PreferenceManager
import com.enaboapps.switchify.components.ActionButton
import com.enaboapps.switchify.components.ActionButtonType
import com.enaboapps.switchify.components.BaseView
import com.enaboapps.switchify.components.InfoCard
import com.enaboapps.switchify.service.llm.model.ReplyDrafterModelConfig
import com.enaboapps.switchify.theme.Dimens

@Composable
fun GemmaTermsScreen(navController: NavController) {
val context = LocalContext.current
val preferenceManager = remember { PreferenceManager(context) }
val alreadyAccepted = remember {
preferenceManager.getBooleanValue(
PreferenceManager.PREFERENCE_KEY_REPLY_DRAFTER_GEMMA_TERMS_ACCEPTED
)
}

BaseView(
titleResId = R.string.screen_title_gemma_terms,
navController = navController
) {
InfoCard(
titleResId = R.string.gemma_terms_intro_title,
descriptionResId = R.string.gemma_terms_intro
)
Spacer(modifier = Modifier.height(Dimens.spaceM))
ActionButton(
textResId = R.string.gemma_terms_view_terms,
onClick = { openUrl(context, ReplyDrafterModelConfig.GEMMA_TERMS_URL) },
type = ActionButtonType.SECONDARY
)
ActionButton(
textResId = R.string.gemma_terms_view_policy,
onClick = { openUrl(context, ReplyDrafterModelConfig.GEMMA_USE_POLICY_URL) },
type = ActionButtonType.SECONDARY
)
if (!alreadyAccepted) {
Spacer(modifier = Modifier.height(Dimens.spaceL))
ActionButton(
textResId = R.string.gemma_terms_accept,
onClick = {
preferenceManager.setBooleanValue(
PreferenceManager.PREFERENCE_KEY_REPLY_DRAFTER_GEMMA_TERMS_ACCEPTED,
true
)
navController.popBackStack()
}
)
}
}
}

private fun openUrl(context: Context, url: String) {
if (url.isBlank()) return
runCatching {
context.startActivity(Intent(Intent.ACTION_VIEW, url.toUri()))
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
package com.enaboapps.switchify.screens.settings.replydrafter

import android.content.Context
import androidx.lifecycle.LiveData
import androidx.lifecycle.MediatorLiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.work.WorkInfo
import com.enaboapps.switchify.backend.preferences.PreferenceManager
import com.enaboapps.switchify.service.llm.model.ModelDownloadManager
import com.enaboapps.switchify.service.llm.model.ModelManager

sealed interface ModelDownloadUiState {
data object NotDownloaded : ModelDownloadUiState
data object Ready : ModelDownloadUiState
data class Downloading(val bytesDownloaded: Long, val totalBytes: Long) : ModelDownloadUiState
data object Failed : ModelDownloadUiState
}

class ModelDownloadViewModel(context: Context) : ViewModel() {
private val appContext = context.applicationContext
private val modelManager = ModelManager(appContext)
private val preferenceManager = PreferenceManager(appContext)

private val workInfos = ModelDownloadManager.getWorkInfoLiveData(appContext)
private val refreshTrigger = MutableLiveData(Unit)

val uiState: LiveData<ModelDownloadUiState> =
MediatorLiveData<ModelDownloadUiState>().apply {
val update = { value = deriveState(workInfos.value.orEmpty()) }
addSource(workInfos) { update() }
addSource(refreshTrigger) { update() }
}

fun hasEnoughFreeSpace(): Boolean = modelManager.hasEnoughFreeSpace()

fun isTermsAccepted(): Boolean = preferenceManager.getBooleanValue(
PreferenceManager.PREFERENCE_KEY_REPLY_DRAFTER_GEMMA_TERMS_ACCEPTED
)

fun startDownload() {
if (!isTermsAccepted()) return
ModelDownloadManager.enqueue(appContext)
}

fun cancelDownload() {
ModelDownloadManager.cancel(appContext)
}

fun deleteModel() {
modelManager.deleteModel()
refreshTrigger.value = Unit
}

private fun deriveState(infos: List<WorkInfo>): ModelDownloadUiState {
if (modelManager.isModelReady()) return ModelDownloadUiState.Ready
val latest = infos.lastOrNull()
return when (latest?.state) {
WorkInfo.State.ENQUEUED,
WorkInfo.State.RUNNING,
WorkInfo.State.BLOCKED -> ModelDownloadUiState.Downloading(
latest.progress.getLong(ModelDownloadManager.KEY_PROGRESS_BYTES, 0L),
latest.progress.getLong(ModelDownloadManager.KEY_PROGRESS_TOTAL, 0L)
)

WorkInfo.State.FAILED -> ModelDownloadUiState.Failed
else -> ModelDownloadUiState.NotDownloaded
}
}
}
Loading