feat: Implement initial Android launcher application with Compose, including app listing and launching capabilities.

This commit is contained in:
Jesus Hernandez
2025-12-23 00:29:46 -08:00
commit 556cf74ffd
18 changed files with 511 additions and 0 deletions
+54
View File
@@ -0,0 +1,54 @@
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.android)
alias(libs.plugins.compose.compiler)
}
android {
namespace = "com.example.luncher"
compileSdk = 35
defaultConfig {
applicationId = "com.example.luncher"
minSdk = 26
targetSdk = 35
versionCode = 1
versionName = "1.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
buildTypes {
release {
isMinifyEnabled = false
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_11
targetCompatibility = JavaVersion.VERSION_11
}
kotlinOptions {
jvmTarget = "11"
}
buildFeatures {
compose = true
}
}
dependencies {
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.lifecycle.runtime.ktx)
implementation(libs.androidx.activity.compose)
implementation(platform(libs.androidx.compose.bom))
implementation(libs.androidx.ui)
implementation(libs.androidx.ui.graphics)
implementation(libs.androidx.ui.tooling.preview)
implementation(libs.androidx.material3)
implementation(libs.androidx.datastore.preferences)
debugImplementation(libs.androidx.ui.tooling)
}
+40
View File
@@ -0,0 +1,40 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<application
android:allowBackup="true"
android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/backup_rules"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.Luncher"
tools:targetApi="31">
<activity
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleInstance"
android:stateNotNeeded="true"
android:theme="@style/Theme.Luncher">
<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>
<activity
android:name=".SettingsActivity"
android:exported="false"
android:theme="@style/Theme.Luncher" />
</application>
</manifest>
@@ -0,0 +1,100 @@
package com.example.luncher
import android.content.Intent
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.layout.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.lifecycle.lifecycleScope
import com.example.luncher.data.PreferencesRepository
import com.example.luncher.ui.theme.LuncherTheme
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
class MainActivity : ComponentActivity() {
private lateinit var preferencesRepository: PreferencesRepository
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
preferencesRepository = PreferencesRepository(this)
lifecycleScope.launch {
val selectedPackage = preferencesRepository.selectedAppPackage.first()
if (selectedPackage != null) {
launchApp(selectedPackage)
}
}
setContent {
LuncherTheme {
MainScreen(
onOpenSettings = {
startActivity(Intent(this, SettingsActivity::class.java))
}
)
}
}
}
override fun onResume() {
super.onResume()
// Every time the user hits the Home button, this activity is resumed.
// We check if an app is selected and launch it.
lifecycleScope.launch {
val selectedPackage = preferencesRepository.selectedAppPackage.first()
if (selectedPackage != null) {
launchApp(selectedPackage)
}
}
}
private fun launchApp(packageName: String) {
val launchIntent = packageManager.getLaunchIntentForPackage(packageName)
if (launchIntent != null) {
// Use FLAG_ACTIVITY_REORDER_TO_FRONT to bring the existing task to the front
// instead of creating a new one or restarting the app.
launchIntent.addFlags(Intent.FLAG_ACTIVITY_REORDER_TO_FRONT)
launchIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
startActivity(launchIntent)
}
}
}
@Composable
fun MainScreen(onOpenSettings: () -> Unit) {
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background
) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(32.dp),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
text = "Luncher",
style = MaterialTheme.typography.displayMedium,
color = MaterialTheme.colorScheme.primary
)
Spacer(modifier = Modifier.height(16.dp))
Text(
text = "No app selected yet.",
style = MaterialTheme.typography.bodyLarge
)
Spacer(modifier = Modifier.height(32.dp))
Button(onClick = onOpenSettings) {
Text("Open Settings")
}
}
}
}
@@ -0,0 +1,131 @@
package com.example.luncher
import android.content.Context
import android.content.Intent
import android.os.Bundle
import android.provider.Settings
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.Image
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.core.graphics.drawable.toBitmap
import com.example.luncher.data.AppInfo
import com.example.luncher.data.AppRepository
import com.example.luncher.data.PreferencesRepository
import com.example.luncher.ui.theme.LuncherTheme
import kotlinx.coroutines.launch
class SettingsActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
val appRepository = AppRepository(this)
val preferencesRepository = PreferencesRepository(this)
setContent {
LuncherTheme {
SettingsScreen(
appRepository = appRepository,
preferencesRepository = preferencesRepository,
onBack = { finish() }
)
}
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SettingsScreen(
appRepository: AppRepository,
preferencesRepository: PreferencesRepository,
onBack: () -> Unit
) {
val context = LocalContext.current
val scope = rememberCoroutineScope()
val apps = remember { appRepository.getInstalledApps() }
val selectedApp by preferencesRepository.selectedAppPackage.collectAsState(initial = null)
Scaffold(
topBar = {
TopAppBar(
title = { Text("Launcher Settings") },
actions = {
Button(onClick = {
val intent = Intent(Settings.ACTION_HOME_SETTINGS)
context.startActivity(intent)
}) {
Text("Switch Launcher")
}
}
)
}
) { innerPadding ->
LazyColumn(
modifier = Modifier
.padding(innerPadding)
.fillMaxSize()
) {
item {
Text(
"Select the app you want to use as your main interface:",
style = MaterialTheme.typography.bodyLarge,
modifier = Modifier.padding(16.dp)
)
}
items(apps) { app ->
AppItem(
app = app,
isSelected = selectedApp == app.packageName,
onClick = {
scope.launch {
preferencesRepository.setSelectedAppPackage(app.packageName)
onBack()
}
}
)
}
}
}
}
@Composable
fun AppItem(
app: AppInfo,
isSelected: Boolean,
onClick: () -> Unit
) {
ListItem(
modifier = Modifier.clickable { onClick() },
headlineContent = { Text(app.name) },
supportingContent = { Text(app.packageName) },
leadingContent = {
app.icon?.let { icon ->
Image(
bitmap = icon.toBitmap().asImageBitmap(),
contentDescription = null,
modifier = Modifier.size(40.dp)
)
}
},
trailingContent = {
if (isSelected) {
RadioButton(selected = true, onClick = null)
}
}
)
}
@@ -0,0 +1,33 @@
package com.example.luncher.data
import android.content.Context
import android.content.Intent
import android.graphics.drawable.Drawable
data class AppInfo(
val name: String,
val packageName: String,
val icon: Drawable?
)
class AppRepository(private val context: Context) {
fun getInstalledApps(): List<AppInfo> {
val pm = context.packageManager
val mainIntent = Intent(Intent.ACTION_MAIN, null)
mainIntent.addCategory(Intent.CATEGORY_LAUNCHER)
val resolvedInfos = pm.queryIntentActivities(mainIntent, 0)
return resolvedInfos
.filter { it.activityInfo.packageName != context.packageName } // Exclude our own app
.map {
AppInfo(
name = it.loadLabel(pm).toString(),
packageName = it.activityInfo.packageName,
icon = it.loadIcon(pm)
)
}
.sortedBy { it.name }
}
}
@@ -0,0 +1,34 @@
package com.example.luncher.data
import android.content.Context
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.edit
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: DataStore<Preferences> by preferencesDataStore(name = "launcher_settings")
class PreferencesRepository(private val context: Context) {
private object PreferencesKeys {
val SELECTED_APP_PACKAGE = stringPreferencesKey("selected_app_package")
}
val selectedAppPackage: Flow<String?> = context.dataStore.data
.map { preferences ->
preferences[PreferencesKeys.SELECTED_APP_PACKAGE]
}
suspend fun setSelectedAppPackage(packageName: String?) {
context.dataStore.edit { preferences ->
if (packageName == null) {
preferences.remove(PreferencesKeys.SELECTED_APP_PACKAGE)
} else {
preferences[PreferencesKeys.SELECTED_APP_PACKAGE] = packageName
}
}
}
}
@@ -0,0 +1,28 @@
package com.example.luncher.ui.theme
import android.os.Build
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.LocalContext
@Composable
fun LuncherTheme(
darkTheme: Boolean = isSystemInDarkTheme(),
dynamicColor: Boolean = true,
content: @Composable () -> Unit
) {
val colorScheme = when {
dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_31 -> {
val context = LocalContext.current
if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
}
darkTheme -> darkColorScheme()
else -> lightColorScheme()
}
MaterialTheme(
colorScheme = colorScheme,
content = content
)
}
@@ -0,0 +1,12 @@
<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="#FF6200EE"
android:pathData="M54,54m-40,0a40,40 0,1 1,80 0a40,40 0,1 1,-80 0" />
<path
android:fillColor="#FFFFFFFF"
android:pathData="M65,40L43,40C41.34,40 40,41.34 40,43L40,65C40,66.66 41.34,68 43,68L65,68C66.66,68 68,66.66 68,65L68,43C68,41.34 66.66,40 65,40ZM64,64L44,64L44,44L64,44L64,64Z" />
</vector>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/white"/>
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
</adaptive-icon>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/white"/>
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
</adaptive-icon>
+3
View File
@@ -0,0 +1,3 @@
<resources>
<color name="white">#FFFFFFFF</color>
</resources>
+3
View File
@@ -0,0 +1,3 @@
<resources>
<string name="app_name">Luncher</string>
</resources>
+4
View File
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="Theme.Luncher" parent="android:Theme.Material.Light.NoActionBar" />
</resources>
+4
View File
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<full-backup-content>
<include domain="file" path="." />
</full-backup-content>
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<data-extraction-rules>
<cloud-backup>
<include domain="file" path="." />
</cloud-backup>
<device-transfer>
<include domain="file" path="." />
</device-transfer>
</data-extraction-rules>
+5
View File
@@ -0,0 +1,5 @@
plugins {
alias(libs.plugins.android.application) apply false
alias(libs.plugins.kotlin.android) apply false
alias(libs.plugins.compose.compiler) apply false
}
+25
View File
@@ -0,0 +1,25 @@
[versions]
agp = "8.7.3"
kotlin = "2.0.0"
coreKtx = "1.15.0"
lifecycleRuntimeKtx = "2.8.7"
activityCompose = "1.9.3"
composeBom = "2024.12.01"
datastore = "1.1.1"
[libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycleRuntimeKtx" }
androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" }
androidx-ui = { group = "androidx.compose.ui", name = "ui" }
androidx-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" }
androidx-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" }
androidx-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" }
androidx-material3 = { group = "androidx.compose.material3", name = "material3" }
androidx-datastore-preferences = { group = "androidx.datastore", name = "datastore-preferences", version.ref = "datastore" }
[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
+16
View File
@@ -0,0 +1,16 @@
pluginManagement {
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
google()
mavenCentral()
}
}
rootProject.name = "Luncher"
include(":app")