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
7 changes: 7 additions & 0 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,12 @@ import org.jetbrains.kotlin.gradle.dsl.JvmTarget
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.compose)
alias(libs.plugins.ksp)
}




kotlin {
compilerOptions {
jvmTarget.set(JvmTarget.JVM_21)
Expand Down Expand Up @@ -162,6 +164,7 @@ dependencies {
implementation(libs.androidx.compose.foundation.layout)
implementation(libs.androidx.ui)
implementation(libs.androidx.compose.foundation)
implementation(libs.genai.schema)
implementation(libs.material)
implementation(libs.androidx.material3)
implementation(libs.androidx.foundation.layout)
Expand Down Expand Up @@ -232,4 +235,8 @@ dependencies {
// Lottie for animations
implementation(libs.lottie.compose)

// ML Kit GenAI Prompt API & Structured Output Compiler
implementation(libs.mlkit.genai.prompt)
ksp(libs.mlkit.genai.schema.compiler)

}
2 changes: 1 addition & 1 deletion app/proguard-rules.pro
Original file line number Diff line number Diff line change
Expand Up @@ -60,4 +60,4 @@
-keepclassmembers class com.sameerasw.essentials.R$string {
public static <fields>;
}

-keep class com.sameerasw.essentials.domain.genai.** { *; }
19 changes: 19 additions & 0 deletions app/src/main/java/com/sameerasw/essentials/SettingsActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -262,6 +262,13 @@ fun SettingsContent(
val updateInfo by viewModel.updateInfo
val isUpdateAvailable by viewModel.isUpdateAvailable
val isAutoUpdateEnabled by viewModel.isAutoUpdateEnabled
val isGenAIAutomationEnabled by viewModel.isGenAIAutomationEnabled
var isGenAISupported by remember { mutableStateOf(false) }

LaunchedEffect(Unit) {
isGenAISupported = com.sameerasw.essentials.domain.genai.GenAIAutomationService.isSupported()
}

val isUpdateNotificationEnabled by viewModel.isUpdateNotificationEnabled
val isPreReleaseCheckEnabled by viewModel.isPreReleaseCheckEnabled
val isRootEnabled by viewModel.isRootEnabled
Expand Down Expand Up @@ -463,6 +470,8 @@ fun SettingsContent(
)
}



// Updates Section
Text(
text = "Updates",
Expand Down Expand Up @@ -715,6 +724,16 @@ fun SettingsContent(
}
}
)

if (isGenAISupported) {
IconToggleItem(
iconRes = R.drawable.rounded_auto_awesome_24,
title = stringResource(R.string.settings_genai_automation_title),
description = stringResource(R.string.settings_genai_automation_desc),
isChecked = isGenAIAutomationEnabled,
onCheckedChange = { viewModel.setGenAIAutomationEnabled(it, context) }
)
}
}

Spacer(modifier = Modifier.height(16.dp))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,9 @@ class SettingsRepository(private val context: Context) {
const val PREFS_NAME = "essentials_prefs"

// Keys
const val KEY_GENAI_AUTOMATION_ENABLED = "genai_automation_enabled"
const val KEY_DAILY_WALLPAPER_LAST_ID = "daily_wallpaper_last_id"

const val KEY_DAILY_WALLPAPER_LAST_URL_MOBILE = "daily_wallpaper_last_url_mobile"
const val KEY_DAILY_WALLPAPER_LAST_URL = "daily_wallpaper_last_url"
const val KEY_DAILY_WALLPAPER_AUTHOR_NAME = "daily_wallpaper_author_name"
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
package com.sameerasw.essentials.domain.genai

import androidx.annotation.Keep
import com.google.mlkit.genai.schema.annotations.Generable
import com.google.mlkit.genai.schema.annotations.Guide


@Keep
@Generable("Structured output representing an automation suggestion generated by Gemini Nano")
data class AutomationSuggestion(
@Guide(
description = "Type of automation: TRIGGER, STATE, or APP",
enumValues = ["TRIGGER", "STATE", "APP"]
)
val type: String = "TRIGGER",

@Guide(description = "Human-readable suggested title for the automation")
val title: String = "",

@Guide(
description = "Trigger type if automation is TRIGGER type",
enumValues = [
"ScreenOff",
"ScreenOn",
"DeviceUnlock",
"ChargerConnected",
"ChargerDisconnected",
"Schedule",
"BluetoothConnected",
"BluetoothDisconnected",
"WifiConnected",
"WifiDisconnected"
]
)
val triggerType: String? = null,

@Guide(
description = "State type if automation is STATE type",
enumValues = ["Charging", "ScreenOn", "TimePeriod"]
)
val stateType: String? = null,

@Guide(
description = "List of action names to execute",
minItems = 1,
maxItems = 5
)
val actionTypes: List<String> = emptyList(),

@Guide(description = "Brief human-readable summary of what this automation will do")
val explanation: String = ""
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
package com.sameerasw.essentials.domain.genai

import android.content.Context
import android.util.Log
import com.google.gson.Gson
import com.google.mlkit.genai.common.FeatureStatus
import com.google.mlkit.genai.prompt.Generation
import com.google.mlkit.genai.prompt.TextPart
import com.google.mlkit.genai.prompt.generateContentRequest
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext

object GenAIAutomationService {
private const val TAG = "GenAIAutomationService"
private val gson = Gson()

suspend fun isSupported(): Boolean = withContext(Dispatchers.IO) {
try {
val generativeModel = Generation.getClient()
val status = generativeModel.checkStatus()
status == FeatureStatus.AVAILABLE || status == FeatureStatus.DOWNLOADABLE || status == FeatureStatus.DOWNLOADING
} catch (e: Exception) {
Log.e(TAG, "Failed to check GenAI status", e)
false
}
}

suspend fun suggestAutomation(userPrompt: String): Result<AutomationSuggestion> = withContext(Dispatchers.IO) {
try {
val generativeModel = Generation.getClient()
when (generativeModel.checkStatus()) {

FeatureStatus.UNAVAILABLE -> {
return@withContext Result.failure(IllegalStateException("GenAI feature is UNAVAILABLE on this device"))
}
FeatureStatus.DOWNLOADABLE -> {
Log.d(TAG, "Gemini Nano model is downloadable. Triggering download...")
var downloadFailedReason: String? = null
generativeModel.download().collect { downloadStatus ->
when (downloadStatus) {
is com.google.mlkit.genai.common.DownloadStatus.DownloadFailed -> {
downloadFailedReason = downloadStatus.e.message
}
else -> {}
}
}
if (downloadFailedReason != null) {
return@withContext Result.failure(IllegalStateException("Gemini Nano download failed: $downloadFailedReason"))
}
}
FeatureStatus.DOWNLOADING -> {
Log.d(TAG, "Gemini Nano model is currently downloading...")
generativeModel.download().collect {}
}
FeatureStatus.AVAILABLE -> {
// Ready to proceed
}
}


val systemInstructionText = """
You are an automation assistant for the Essentials Android app.
Map the user's description into a single JSON object matching the following structure:
{
"type": "TRIGGER" or "STATE" or "APP",
"title": "Short descriptive title",
"triggerType": "ScreenOff" or "ScreenOn" or "DeviceUnlock" or "ChargerConnected" or "ChargerDisconnected" or "Schedule" or "BluetoothConnected" or "BluetoothDisconnected" or "WifiConnected" or "WifiDisconnected",
"stateType": "Charging" or "ScreenOn" or "TimePeriod",
"actionTypes": ["HapticVibration", "ShowNotification", "RemoveNotification", "TurnOnFlashlight", "TurnOffFlashlight", "ToggleFlashlight", "DimWallpaper", "DeviceEffects", "SoundMode", "TurnOnLowPower", "TurnOffLowPower", "ScreenOff", "MediaPlayPause", "MediaNext", "MediaPrevious", "AIAssistant", "TakeScreenshot", "ToggleMediaVolume", "LikeCurrentSong", "CircleToSearch", "PinApp", "SometimesEssentials", "FreezeTag"],
"explanation": "Brief 1-2 sentence description of what this automation does"
}

When user mentions freezing or unfreezing apps, background apps, or apps with tags, map it to action "FreezeTag".

Respond ONLY with valid raw JSON without any markdown formatting or code blocks.
""".trimIndent()

val contextPrompt = """
$systemInstructionText

User request: "$userPrompt"
""".trimIndent()

val request = generateContentRequest(TextPart(contextPrompt)) {
temperature = 0.2f
}

val response = generativeModel.generateContent(request)
val candidateText = response.candidates.firstOrNull()?.text ?: ""

// Clean up possible markdown code fences from the output
val jsonText = candidateText
.replace("```json", "")
.replace("```", "")
.trim()

if (jsonText.isNotEmpty()) {
val suggestion = gson.fromJson(jsonText, AutomationSuggestion::class.java)
if (suggestion != null) {
Result.success(suggestion)
} else {
Result.failure(Exception("Failed to parse JSON response into AutomationSuggestion"))
}
} else {
val finishReason = response.candidates.firstOrNull()?.finishReason
Result.failure(Exception("AI response was empty. Finish reason: $finishReason"))
}
} catch (e: Exception) {
Log.e(TAG, "Error generating automation suggestion", e)
Result.failure(e)
}
}
}

Original file line number Diff line number Diff line change
Expand Up @@ -193,7 +193,7 @@ class FavoritesWidget : GlanceAppWidget() {
.cornerRadius(
iconCornerRadius
)
.background(pastelColor),
.background(ColorProvider(pastelColor)),
contentAlignment = Alignment.Center
) {
Image(
Expand Down Expand Up @@ -246,7 +246,7 @@ class FavoritesWidget : GlanceAppWidget() {
.cornerRadius(
iconCornerRadius
)
.background(pastelColor),
.background(ColorProvider(pastelColor)),
contentAlignment = Alignment.Center
) {
Image(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -216,7 +216,7 @@ class QsTilesWidget : GlanceAppWidget() {
.cornerRadius(
iconCornerRadius
)
.background(pastelColor),
.background(ColorProvider(pastelColor)),
contentAlignment = Alignment.Center
) {
Image(
Expand Down
Loading
Loading