commit 556cf74ffd3df6e87ff41c37504281f3e723669b Author: Jesus Hernandez Date: Tue Dec 23 00:29:46 2025 -0800 feat: Implement initial Android launcher application with Compose, including app listing and launching capabilities. diff --git a/app/build.gradle.kts b/app/build.gradle.kts new file mode 100644 index 0000000..fc82111 --- /dev/null +++ b/app/build.gradle.kts @@ -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) +} diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..f0fdbf3 --- /dev/null +++ b/app/src/main/AndroidManifest.xml @@ -0,0 +1,40 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/java/com/example/luncher/MainActivity.kt b/app/src/main/java/com/example/luncher/MainActivity.kt new file mode 100644 index 0000000..eac9aca --- /dev/null +++ b/app/src/main/java/com/example/luncher/MainActivity.kt @@ -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") + } + } + } +} diff --git a/app/src/main/java/com/example/luncher/SettingsActivity.kt b/app/src/main/java/com/example/luncher/SettingsActivity.kt new file mode 100644 index 0000000..b4d72c0 --- /dev/null +++ b/app/src/main/java/com/example/luncher/SettingsActivity.kt @@ -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) + } + } + ) +} diff --git a/app/src/main/java/com/example/luncher/data/AppRepository.kt b/app/src/main/java/com/example/luncher/data/AppRepository.kt new file mode 100644 index 0000000..c3593bc --- /dev/null +++ b/app/src/main/java/com/example/luncher/data/AppRepository.kt @@ -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 { + 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 } + } +} diff --git a/app/src/main/java/com/example/luncher/data/PreferencesRepository.kt b/app/src/main/java/com/example/luncher/data/PreferencesRepository.kt new file mode 100644 index 0000000..396c2f3 --- /dev/null +++ b/app/src/main/java/com/example/luncher/data/PreferencesRepository.kt @@ -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 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 = 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 + } + } + } +} diff --git a/app/src/main/java/com/example/luncher/ui/theme/Theme.kt b/app/src/main/java/com/example/luncher/ui/theme/Theme.kt new file mode 100644 index 0000000..8d3d4ac --- /dev/null +++ b/app/src/main/java/com/example/luncher/ui/theme/Theme.kt @@ -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 + ) +} diff --git a/app/src/main/res/drawable/ic_launcher_foreground.xml b/app/src/main/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 0000000..572884c --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,12 @@ + + + + diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000..5005f34 --- /dev/null +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 0000000..5005f34 --- /dev/null +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml new file mode 100644 index 0000000..6f9b037 --- /dev/null +++ b/app/src/main/res/values/colors.xml @@ -0,0 +1,3 @@ + + #FFFFFFFF + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..35a0749 --- /dev/null +++ b/app/src/main/res/values/strings.xml @@ -0,0 +1,3 @@ + + Luncher + diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml new file mode 100644 index 0000000..dd2deff --- /dev/null +++ b/app/src/main/res/values/themes.xml @@ -0,0 +1,4 @@ + + +