Ambient Launcher #1

This commit is contained in:
jahruz67
2026-07-25 14:25:23 -07:00
commit 29fc34dbdf
17 changed files with 1623 additions and 0 deletions
+53
View File
@@ -0,0 +1,53 @@
plugins {
id("com.android.application")
id("org.jetbrains.kotlin.android")
id("org.jetbrains.kotlin.plugin.compose")
}
android {
namespace = "com.ambient.launcher"
compileSdk = 35
defaultConfig {
applicationId = "com.ambient.launcher"
minSdk = 26
targetSdk = 35
versionCode = 1
versionName = "1.0"
}
buildFeatures {
compose = true
buildConfig = true
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = "17"
}
packaging {
resources.excludes += "/META-INF/{AL2.0,LGPL2.1}"
}
}
dependencies {
implementation(platform("androidx.compose:compose-bom:2024.12.01"))
implementation("androidx.core:core-ktx:1.15.0")
implementation("androidx.activity:activity-compose:1.10.0")
implementation("androidx.compose.ui:ui")
implementation("androidx.compose.ui:ui-tooling-preview")
implementation("androidx.compose.material3:material3")
implementation("androidx.compose.material:material-icons-extended")
implementation("androidx.lifecycle:lifecycle-runtime-compose:2.8.7")
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.8.7")
implementation("androidx.datastore:datastore-preferences:1.1.1")
implementation("androidx.documentfile:documentfile:1.0.1")
implementation("io.coil-kt:coil-compose:2.7.0")
debugImplementation("androidx.compose.ui:ui-tooling")
}
+1
View File
@@ -0,0 +1 @@
# No custom rules are required.
+39
View File
@@ -0,0 +1,39 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<queries>
<intent>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent>
</queries>
<application
android:allowBackup="true"
android:icon="@drawable/ic_launcher"
android:label="@string/app_name"
android:supportsRtl="true"
android:theme="@style/Theme.AmbientLauncher">
<activity
android:name=".MainActivity"
android:configChanges="orientation|screenSize|smallestScreenSize|screenLayout|uiMode"
android:excludeFromRecents="true"
android:exported="true"
android:launchMode="singleTask"
android:screenOrientation="userLandscape">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.HOME" />
<category android:name="android.intent.category.DEFAULT" />
</intent-filter>
</activity>
</application>
</manifest>
@@ -0,0 +1,159 @@
package com.ambient.launcher
import android.app.Application
import android.content.ComponentName
import android.content.Intent
import android.content.pm.PackageManager
import androidx.documentfile.provider.DocumentFile
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
data class LauncherUiState(
val settings: LauncherSettings = LauncherSettings(),
val apps: List<LauncherApp> = emptyList(),
val localImages: List<Any> = emptyList(),
val weather: WeatherNow? = null,
val weatherLoading: Boolean = false,
val weatherError: String? = null
)
class LauncherViewModel(application: Application) : AndroidViewModel(application) {
private val store = SettingsStore(application)
private val weatherRepository = WeatherRepository()
private val _uiState = MutableStateFlow(LauncherUiState())
val uiState: StateFlow<LauncherUiState> = _uiState.asStateFlow()
init {
loadApps()
viewModelScope.launch {
store.settings.collectLatest { settings ->
val old = _uiState.value.settings
_uiState.value = _uiState.value.copy(settings = settings)
if (settings.localFolderUri != old.localFolderUri) loadLocalImages(settings.localFolderUri)
if (
settings.useFahrenheit != old.useFahrenheit ||
_uiState.value.weather == null
) {
refreshWeather()
}
}
}
}
fun updateSettings(transform: (LauncherSettings) -> LauncherSettings) {
val updated = transform(_uiState.value.settings)
_uiState.value = _uiState.value.copy(settings = updated)
viewModelScope.launch { store.save(updated) }
}
fun togglePinned(app: LauncherApp) {
updateSettings { settings ->
val id = app.component.flattenToString()
val next = settings.pinnedComponents.toMutableSet().apply {
if (!add(id)) remove(id)
}
settings.copy(pinnedComponents = next)
}
}
fun setLocalFolder(uri: String) {
updateSettings { it.copy(localFolderUri = uri) }
loadLocalImages(uri)
}
fun refreshWeather() {
val settings = _uiState.value.settings
val lat = settings.latitude.toDoubleOrNull()
val lon = settings.longitude.toDoubleOrNull()
if (lat == null || lon == null || lat !in -90.0..90.0 || lon !in -180.0..180.0) {
_uiState.value = _uiState.value.copy(
weatherLoading = false,
weatherError = "Enter valid latitude and longitude."
)
return
}
viewModelScope.launch {
_uiState.value = _uiState.value.copy(weatherLoading = true, weatherError = null)
runCatching { weatherRepository.load(lat, lon, settings.useFahrenheit) }
.onSuccess {
_uiState.value = _uiState.value.copy(
weather = it,
weatherLoading = false,
weatherError = null
)
}
.onFailure {
_uiState.value = _uiState.value.copy(
weatherLoading = false,
weatherError = "Weather unavailable. Check the connection."
)
}
}
}
fun launch(app: LauncherApp): Boolean {
val intent = Intent(Intent.ACTION_MAIN)
.addCategory(Intent.CATEGORY_LAUNCHER)
.setComponent(app.component)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_RESET_TASK_IF_NEEDED)
return runCatching {
getApplication<Application>().startActivity(intent)
}.isSuccess
}
private fun loadApps() {
viewModelScope.launch {
val result = withContext(Dispatchers.IO) {
val context = getApplication<Application>()
val pm = context.packageManager
val intent = Intent(Intent.ACTION_MAIN).addCategory(Intent.CATEGORY_LAUNCHER)
val flags = if (android.os.Build.VERSION.SDK_INT >= 33) {
PackageManager.ResolveInfoFlags.of(PackageManager.MATCH_ALL.toLong())
} else null
val resolved = if (flags != null) {
pm.queryIntentActivities(intent, flags)
} else {
@Suppress("DEPRECATION")
pm.queryIntentActivities(intent, PackageManager.MATCH_ALL)
}
resolved.mapNotNull { info ->
val activity = info.activityInfo ?: return@mapNotNull null
if (activity.packageName == context.packageName) return@mapNotNull null
LauncherApp(
label = info.loadLabel(pm).toString(),
component = ComponentName(activity.packageName, activity.name),
icon = info.loadIcon(pm)
)
}.distinctBy { it.component }.sortedBy { it.label.lowercase() }
}
_uiState.value = _uiState.value.copy(apps = result)
}
}
private fun loadLocalImages(uri: String) {
viewModelScope.launch {
val images = withContext(Dispatchers.IO) {
if (uri.isBlank()) return@withContext emptyList()
val context = getApplication<Application>()
runCatching {
DocumentFile.fromTreeUri(context, android.net.Uri.parse(uri))
?.listFiles()
?.asSequence()
?.filter { it.isFile && it.type?.startsWith("image/") == true }
?.mapNotNull { it.uri }
?.take(250)
?.toList()
?: emptyList()
}.getOrDefault(emptyList())
}
_uiState.value = _uiState.value.copy(localImages = images)
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,87 @@
package com.ambient.launcher
import android.content.ComponentName
import android.graphics.drawable.Drawable
data class LauncherApp(
val label: String,
val component: ComponentName,
val icon: Drawable
)
data class WeatherNow(
val temperature: Double,
val apparentTemperature: Double,
val weatherCode: Int,
val high: Double,
val low: Double,
val humidity: Int,
val windSpeed: Double,
val hourly: List<HourlyWeather>,
val daily: List<DailyWeather>
)
data class HourlyWeather(
val time: String,
val temperature: Double,
val precipitationChance: Int
)
data class DailyWeather(
val date: String,
val high: Double,
val low: Double,
val weatherCode: Int
)
enum class KeepAwakeMode { WHILE_VISIBLE, WHILE_CHARGING, SYSTEM_DEFAULT }
enum class ClockCorner { BOTTOM_LEFT, BOTTOM_RIGHT, TOP_LEFT, TOP_RIGHT }
data class LauncherSettings(
val intervalSeconds: Int = 60,
val darkenPercent: Int = 18,
val keepAwakeMode: KeepAwakeMode = KeepAwakeMode.WHILE_CHARGING,
val clockCorner: ClockCorner = ClockCorner.BOTTOM_RIGHT,
val showWeather: Boolean = true,
val locationName: String = "San Francisco",
val latitude: String = "37.7749",
val longitude: String = "-122.4194",
val useFahrenheit: Boolean = true,
val weatherEveryPhotos: Int = 5,
val localFolderUri: String = "",
val pinnedComponents: Set<String> = emptySet(),
val nightRoutineEnabled: Boolean = false,
val nightStartHour: Int = 23,
val nightEndHour: Int = 7,
val nightDimPercent: Int = 88
)
sealed interface Slide {
data class Photo(val source: Any, val credit: String) : Slide
data object Weather : Slide
}
fun weatherDescription(code: Int): String = when (code) {
0 -> "Clear"
1, 2 -> "Partly cloudy"
3 -> "Overcast"
45, 48 -> "Fog"
51, 53, 55, 56, 57 -> "Drizzle"
61, 63, 65, 66, 67 -> "Rain"
71, 73, 75, 77 -> "Snow"
80, 81, 82 -> "Rain showers"
85, 86 -> "Snow showers"
95, 96, 99 -> "Thunderstorm"
else -> "Unknown"
}
fun weatherGlyph(code: Int): String = when (code) {
0 -> ""
1, 2 -> "🌤"
3 -> ""
45, 48 -> "🌫"
51, 53, 55, 56, 57, 61, 63, 65, 66, 67, 80, 81, 82 -> "🌧"
71, 73, 75, 77, 85, 86 -> ""
95, 96, 99 -> ""
else -> ""
}
@@ -0,0 +1,82 @@
package com.ambient.launcher
import android.content.Context
import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.intPreferencesKey
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
private val Context.dataStore by preferencesDataStore("ambient_launcher")
class SettingsStore(private val context: Context) {
private object Keys {
val interval = intPreferencesKey("interval")
val darken = intPreferencesKey("darken")
val keepAwake = stringPreferencesKey("keep_awake")
val corner = stringPreferencesKey("clock_corner")
val showWeather = booleanPreferencesKey("show_weather")
val locationName = stringPreferencesKey("location_name")
val latitude = stringPreferencesKey("latitude")
val longitude = stringPreferencesKey("longitude")
val fahrenheit = booleanPreferencesKey("fahrenheit")
val weatherFrequency = intPreferencesKey("weather_frequency")
val localFolder = stringPreferencesKey("local_folder")
val pinned = stringPreferencesKey("pinned")
val nightEnabled = booleanPreferencesKey("night_enabled")
val nightStart = intPreferencesKey("night_start")
val nightEnd = intPreferencesKey("night_end")
val nightDim = intPreferencesKey("night_dim")
}
val settings: Flow<LauncherSettings> = context.dataStore.data.map { p ->
LauncherSettings(
intervalSeconds = p[Keys.interval] ?: 60,
darkenPercent = p[Keys.darken] ?: 18,
keepAwakeMode = enumOrDefault(p[Keys.keepAwake], KeepAwakeMode.WHILE_CHARGING),
clockCorner = enumOrDefault(p[Keys.corner], ClockCorner.BOTTOM_RIGHT),
showWeather = p[Keys.showWeather] ?: true,
locationName = p[Keys.locationName] ?: "San Francisco",
latitude = p[Keys.latitude] ?: "37.7749",
longitude = p[Keys.longitude] ?: "-122.4194",
useFahrenheit = p[Keys.fahrenheit] ?: true,
weatherEveryPhotos = p[Keys.weatherFrequency] ?: 5,
localFolderUri = p[Keys.localFolder] ?: "",
pinnedComponents = p[Keys.pinned]
?.split('|')
?.filter(String::isNotBlank)
?.toSet()
?: emptySet(),
nightRoutineEnabled = p[Keys.nightEnabled] ?: false,
nightStartHour = p[Keys.nightStart] ?: 23,
nightEndHour = p[Keys.nightEnd] ?: 7,
nightDimPercent = p[Keys.nightDim] ?: 88
)
}
suspend fun save(value: LauncherSettings) {
context.dataStore.edit { p ->
p[Keys.interval] = value.intervalSeconds
p[Keys.darken] = value.darkenPercent
p[Keys.keepAwake] = value.keepAwakeMode.name
p[Keys.corner] = value.clockCorner.name
p[Keys.showWeather] = value.showWeather
p[Keys.locationName] = value.locationName
p[Keys.latitude] = value.latitude
p[Keys.longitude] = value.longitude
p[Keys.fahrenheit] = value.useFahrenheit
p[Keys.weatherFrequency] = value.weatherEveryPhotos
p[Keys.localFolder] = value.localFolderUri
p[Keys.pinned] = value.pinnedComponents.joinToString("|")
p[Keys.nightEnabled] = value.nightRoutineEnabled
p[Keys.nightStart] = value.nightStartHour
p[Keys.nightEnd] = value.nightEndHour
p[Keys.nightDim] = value.nightDimPercent
}
}
private inline fun <reified T : Enum<T>> enumOrDefault(raw: String?, default: T): T =
enumValues<T>().firstOrNull { it.name == raw } ?: default
}
@@ -0,0 +1,81 @@
package com.ambient.launcher
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.json.JSONObject
import java.net.HttpURLConnection
import java.net.URL
import java.net.URLEncoder
class WeatherRepository {
suspend fun load(latitude: Double, longitude: Double, fahrenheit: Boolean): WeatherNow =
withContext(Dispatchers.IO) {
val unit = if (fahrenheit) "fahrenheit" else "celsius"
val windUnit = if (fahrenheit) "mph" else "kmh"
val endpoint = buildString {
append("https://api.open-meteo.com/v1/forecast")
append("?latitude=${encode(latitude)}&longitude=${encode(longitude)}")
append("&current=temperature_2m,apparent_temperature,relative_humidity_2m,weather_code,wind_speed_10m")
append("&hourly=temperature_2m,precipitation_probability")
append("&daily=weather_code,temperature_2m_max,temperature_2m_min")
append("&temperature_unit=$unit&wind_speed_unit=$windUnit&timezone=auto&forecast_days=7")
}
val connection = URL(endpoint).openConnection() as HttpURLConnection
connection.connectTimeout = 8_000
connection.readTimeout = 8_000
connection.setRequestProperty("Accept", "application/json")
try {
if (connection.responseCode !in 200..299) {
error("Weather service returned ${connection.responseCode}")
}
val root = JSONObject(connection.inputStream.bufferedReader().use { it.readText() })
parse(root)
} finally {
connection.disconnect()
}
}
private fun parse(root: JSONObject): WeatherNow {
val current = root.getJSONObject("current")
val daily = root.getJSONObject("daily")
val hourly = root.getJSONObject("hourly")
val dailyTime = daily.getJSONArray("time")
val dailyCodes = daily.getJSONArray("weather_code")
val highs = daily.getJSONArray("temperature_2m_max")
val lows = daily.getJSONArray("temperature_2m_min")
val hourlyTimes = hourly.getJSONArray("time")
val hourlyTemps = hourly.getJSONArray("temperature_2m")
val rain = hourly.getJSONArray("precipitation_probability")
val currentTime = current.getString("time")
var start = 0
for (i in 0 until hourlyTimes.length()) {
if (hourlyTimes.getString(i) >= currentTime) {
start = i
break
}
}
return WeatherNow(
temperature = current.getDouble("temperature_2m"),
apparentTemperature = current.getDouble("apparent_temperature"),
weatherCode = current.getInt("weather_code"),
high = highs.getDouble(0),
low = lows.getDouble(0),
humidity = current.getInt("relative_humidity_2m"),
windSpeed = current.getDouble("wind_speed_10m"),
hourly = (start until minOf(start + 8, hourlyTimes.length())).map { i ->
HourlyWeather(hourlyTimes.getString(i), hourlyTemps.getDouble(i), rain.optInt(i))
},
daily = (0 until minOf(7, dailyTime.length())).map { i ->
DailyWeather(
dailyTime.getString(i),
highs.getDouble(i),
lows.getDouble(i),
dailyCodes.getInt(i)
)
}
)
}
private fun encode(value: Double): String =
URLEncoder.encode(value.toString(), Charsets.UTF_8.name())
}
@@ -0,0 +1,8 @@
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<gradient
android:angle="315"
android:endColor="#16243A"
android:startColor="#594755"
android:type="linear" />
</shape>
@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path android:fillColor="#132238" android:pathData="M0,0h108v108h-108z" />
<path android:fillColor="#84BFEA" android:pathData="M0,78L27,46L43,63L67,30L108,78V108H0z" />
<path android:fillColor="#F6D58D" android:pathData="M77,19a11,11 0,1 0,0.1 0z" />
</vector>
+3
View File
@@ -0,0 +1,3 @@
<resources>
<string name="app_name">Ambient Launcher</string>
</resources>
+11
View File
@@ -0,0 +1,11 @@
<resources>
<style name="Theme.AmbientLauncher" parent="android:style/Theme.Material.NoActionBar">
<item name="android:fontFamily">sans</item>
<item name="android:windowActionModeOverlay">true</item>
<item name="android:windowLightStatusBar">false</item>
<item name="android:colorAccent">#D4E7FF</item>
<item name="android:navigationBarColor">#000000</item>
<item name="android:statusBarColor">#000000</item>
<item name="android:windowNoTitle">true</item>
</style>
</resources>