diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml index 9a671f0..209b346 100644 --- a/.github/FUNDING.yml +++ b/.github/FUNDING.yml @@ -1,3 +1,3 @@ -# How you can support finnmglas/Launcher +# How you can support jrpie/Launcher -custom: sponsor.finnmglas.com +custom: https://s.jrpie.de/launcher-donate diff --git a/.scripts/release.sh b/.scripts/release.sh index 0c71f4a..f207c87 100755 --- a/.scripts/release.sh +++ b/.scripts/release.sh @@ -1,5 +1,5 @@ #!/bin/bash -export JAVA_HOME="/usr/lib/jvm/java-23-openjdk/" +export JAVA_HOME="/usr/lib/jvm/java-21-openjdk/" OUTPUT_DIR="$HOME/launcher-release" BUILD_TOOLS_DIR="$HOME/Android/Sdk/build-tools/35.0.0" KEYSTORE="$HOME/data/keys/launcher_jrpie.jks" diff --git a/app/build.gradle b/app/build.gradle index 103ce56..eaf97f7 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -23,8 +23,8 @@ android { minSdkVersion 21 targetSdkVersion 35 compileSdk 35 - versionCode 39 - versionName "0.0.23" + versionCode 44 + versionName "0.1.4" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" } @@ -85,17 +85,17 @@ android { // Disables dependency metadata when building Android App Bundles. includeInBundle = false } - - lintOptions { + lint { abortOnError false } + } dependencies { implementation fileTree(dir: 'libs', include: ['*.jar']) implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version" - implementation 'androidx.activity:activity:1.8.0' + implementation 'androidx.activity:activity-ktx:1.8.0' implementation 'androidx.appcompat:appcompat:1.7.0' implementation 'androidx.core:core-ktx:1.15.0' implementation 'androidx.constraintlayout:constraintlayout:2.2.0' @@ -106,6 +106,7 @@ dependencies { implementation 'com.google.android.material:material:1.12.0' implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3") implementation "eu.jonahbauer:android-preference-annotations:1.1.2" + implementation 'androidx.activity:activity:1.10.1' annotationProcessor "eu.jonahbauer:android-preference-annotations:1.1.2" annotationProcessor "com.android.databinding:compiler:$android_plugin_version" testImplementation 'junit:junit:4.13.2' diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 93f6ce8..5a1d5a0 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -8,6 +8,7 @@ tools:ignore="QueryAllPackagesPermission" /> + + + + + diff --git a/app/src/main/java/de/jrpie/android/launcher/Application.kt b/app/src/main/java/de/jrpie/android/launcher/Application.kt index 810fceb..775621c 100644 --- a/app/src/main/java/de/jrpie/android/launcher/Application.kt +++ b/app/src/main/java/de/jrpie/android/launcher/Application.kt @@ -7,24 +7,37 @@ import android.content.IntentFilter import android.content.SharedPreferences import android.content.pm.LauncherApps import android.content.pm.ShortcutInfo -import android.os.AsyncTask import android.os.Build import android.os.Build.VERSION_CODES import android.os.UserHandle import androidx.core.content.ContextCompat import androidx.lifecycle.MutableLiveData +import android.appwidget.AppWidgetHost +import android.appwidget.AppWidgetManager import androidx.preference.PreferenceManager import de.jrpie.android.launcher.actions.TorchManager -import de.jrpie.android.launcher.apps.AppInfo -import de.jrpie.android.launcher.apps.DetailedAppInfo +import de.jrpie.android.launcher.apps.AbstractAppInfo +import de.jrpie.android.launcher.apps.AbstractDetailedAppInfo import de.jrpie.android.launcher.apps.isPrivateSpaceLocked import de.jrpie.android.launcher.preferences.LauncherPreferences import de.jrpie.android.launcher.preferences.migratePreferencesToNewVersion import de.jrpie.android.launcher.preferences.resetPreferences +import de.jrpie.android.launcher.widgets.LauncherWidgetProvider +import de.jrpie.android.launcher.widgets.Widget +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch + + +const val APP_WIDGET_HOST_ID = 42; + class Application : android.app.Application() { - val apps = MutableLiveData>() + val apps = MutableLiveData>() + val widgets = MutableLiveData>() val privateSpaceLocked = MutableLiveData() + lateinit var appWidgetHost: AppWidgetHost + lateinit var appWidgetManager: AppWidgetManager private val profileAvailabilityBroadcastReceiver = object : BroadcastReceiver() { override fun onReceive(context: Context?, intent: Intent?) { @@ -82,10 +95,14 @@ class Application : android.app.Application() { } var torchManager: TorchManager? = null - private var customAppNames: HashMap? = null + private var customAppNames: HashMap? = null private val listener = SharedPreferences.OnSharedPreferenceChangeListener { _, pref -> if (pref == getString(R.string.settings_apps_custom_names_key)) { customAppNames = LauncherPreferences.apps().customNames() + } else if (pref == LauncherPreferences.apps().keys().pinnedShortcuts()) { + loadApps() + } else if (pref == LauncherPreferences.widgets().keys().widgets()) { + widgets.postValue(LauncherPreferences.widgets().widgets() ?: setOf()) } } @@ -99,19 +116,22 @@ class Application : android.app.Application() { torchManager = TorchManager(this) } + appWidgetHost = AppWidgetHost(this.applicationContext, APP_WIDGET_HOST_ID) + appWidgetManager = AppWidgetManager.getInstance(this.applicationContext) + + appWidgetHost.startListening() + + val preferences = PreferenceManager.getDefaultSharedPreferences(this) LauncherPreferences.init(preferences, this.resources) - // Try to restore old preferences migratePreferencesToNewVersion(this) - // First time opening the app: set defaults and start tutorial + // First time opening the app: set defaults + // The tutorial is started from HomeActivity#onStart, as starting it here is blocked by android if (!LauncherPreferences.internal().started()) { resetPreferences(this) - - LauncherPreferences.internal().started(true) - openTutorial(this) } @@ -132,7 +152,8 @@ class Application : android.app.Application() { it.addAction(Intent.ACTION_MANAGED_PROFILE_UNAVAILABLE) } } - ContextCompat.registerReceiver(this, profileAvailabilityBroadcastReceiver, filter, + ContextCompat.registerReceiver( + this, profileAvailabilityBroadcastReceiver, filter, ContextCompat.RECEIVER_EXPORTED ) } @@ -143,13 +164,21 @@ class Application : android.app.Application() { loadApps() } - fun getCustomAppNames(): HashMap { + fun getCustomAppNames(): HashMap { return (customAppNames ?: LauncherPreferences.apps().customNames() ?: HashMap()) .also { customAppNames = it } } private fun loadApps() { privateSpaceLocked.postValue(isPrivateSpaceLocked(this)) - AsyncTask.execute { apps.postValue(getApps(packageManager, applicationContext)) } + CoroutineScope(Dispatchers.Default).launch { + apps.postValue(getApps(packageManager, applicationContext)) + } + } + + override fun onTerminate() { + appWidgetHost.stopListening() + super.onTerminate() + } } diff --git a/app/src/main/java/de/jrpie/android/launcher/Functions.kt b/app/src/main/java/de/jrpie/android/launcher/Functions.kt index 8fc95a3..9679ae5 100644 --- a/app/src/main/java/de/jrpie/android/launcher/Functions.kt +++ b/app/src/main/java/de/jrpie/android/launcher/Functions.kt @@ -6,13 +6,15 @@ import android.app.role.RoleManager import android.content.ActivityNotFoundException import android.content.ClipData import android.content.ClipboardManager +import android.appwidget.AppWidgetManager +import android.appwidget.AppWidgetProvider +import android.appwidget.AppWidgetProviderInfo import android.content.Context import android.content.Intent import android.content.pm.LauncherApps import android.content.pm.LauncherApps.ShortcutQuery import android.content.pm.PackageManager import android.content.pm.ShortcutInfo -import android.net.Uri import android.os.Build import android.os.Bundle import android.os.UserHandle @@ -24,24 +26,23 @@ import androidx.annotation.RequiresApi import de.jrpie.android.launcher.actions.Action import de.jrpie.android.launcher.actions.Gesture import de.jrpie.android.launcher.actions.ShortcutAction -import de.jrpie.android.launcher.actions.shortcuts.PinnedShortcutInfo +import de.jrpie.android.launcher.apps.AbstractAppInfo.Companion.INVALID_USER +import de.jrpie.android.launcher.apps.AbstractDetailedAppInfo import de.jrpie.android.launcher.apps.AppInfo import de.jrpie.android.launcher.apps.DetailedAppInfo +import de.jrpie.android.launcher.apps.DetailedPinnedShortcutInfo +import de.jrpie.android.launcher.apps.PinnedShortcutInfo import de.jrpie.android.launcher.apps.getPrivateSpaceUser import de.jrpie.android.launcher.apps.isPrivateSpaceSupported import de.jrpie.android.launcher.preferences.LauncherPreferences import de.jrpie.android.launcher.ui.tutorial.TutorialActivity +import androidx.core.net.toUri -/* REQUEST CODES */ - -const val REQUEST_CHOOSE_APP = 1 -const val REQUEST_UNINSTALL = 2 - -const val REQUEST_SET_DEFAULT_HOME = 42 - const val LOG_TAG = "Launcher" +const val REQUEST_SET_DEFAULT_HOME = 42 + fun isDefaultHomeScreen(context: Context): Boolean { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { val roleManager = context.getSystemService(RoleManager::class.java) @@ -63,7 +64,7 @@ fun setDefaultHomeScreen(context: Context, checkDefault: Boolean = false) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q && context is Activity - && !isDefault // using role manager only works when µLauncher is not already the default. + && checkDefault // using role manager only works when µLauncher is not already the default. ) { val roleManager = context.getSystemService(RoleManager::class.java) context.startActivityForResult( @@ -90,18 +91,24 @@ fun getUserFromId(userId: Int?, context: Context): UserHandle { fun removeUnusedShortcuts(context: Context) { val launcherApps = context.getSystemService(Service.LAUNCHER_APPS_SERVICE) as LauncherApps fun getShortcuts(profile: UserHandle): List? { - return launcherApps.getShortcuts( - ShortcutQuery().apply { - setQueryFlags(ShortcutQuery.FLAG_MATCH_PINNED) - }, - profile - ) + return try { + launcherApps.getShortcuts( + ShortcutQuery().apply { + setQueryFlags(ShortcutQuery.FLAG_MATCH_PINNED) + }, + profile + ) + } catch (e: Exception) { + // https://github.com/jrpie/launcher/issues/116 + return null + } } val userManager = context.getSystemService(Service.USER_SERVICE) as UserManager - val boundActions: Set = + val boundActions: MutableSet = Gesture.entries.mapNotNull { Action.forGesture(it) as? ShortcutAction }.map { it.shortcut } - .toSet() + .toMutableSet() + LauncherPreferences.apps().pinnedShortcuts()?.let { boundActions.addAll(it) } try { userManager.userProfiles.filter { !userManager.isQuietModeEnabled(it) }.forEach { profile -> getShortcuts(profile)?.groupBy { it.`package` }?.forEach { (p, shortcuts) -> @@ -116,7 +123,7 @@ fun removeUnusedShortcuts(context: Context) { } fun openInBrowser(url: String, context: Context) { - val intent = Intent(Intent.ACTION_VIEW, Uri.parse(url)) + val intent = Intent(Intent.ACTION_VIEW, url.toUri()) intent.putExtras(Bundle().apply { putBoolean("new_window", true) }) try { context.startActivity(intent) @@ -126,18 +133,19 @@ fun openInBrowser(url: String, context: Context) { } fun openTutorial(context: Context) { - context.startActivity(Intent(context, TutorialActivity::class.java).apply { - addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) - }) + context.startActivity(Intent(context, TutorialActivity::class.java)) } /** * Load all apps. */ -fun getApps(packageManager: PackageManager, context: Context): MutableList { - val start = System.currentTimeMillis() - val loadList = mutableListOf() +fun getApps( + packageManager: PackageManager, + context: Context +): MutableList { + var start = System.currentTimeMillis() + val loadList = mutableListOf() val launcherApps = context.getSystemService(Service.LAUNCHER_APPS_SERVICE) as LauncherApps val userManager = context.getSystemService(Service.USER_SERVICE) as UserManager @@ -174,7 +182,7 @@ fun getApps(packageManager: PackageManager, context: Context): MutableList= Build.VERSION_CODES.N_MR1) { + start = System.currentTimeMillis() + LauncherPreferences.apps().pinnedShortcuts() + ?.mapNotNull { DetailedPinnedShortcutInfo.fromPinnedShortcutInfo(it, context) } + ?.let { + end = System.currentTimeMillis() + Log.i(LOG_TAG, "${it.size} shortcuts loaded (${end - start}ms)") + loadList.addAll(it) + } + } return loadList } - -// Used in Tutorial and Settings `ActivityOnResult` -fun saveListActivityChoice(data: Intent?) { - val forGesture = data?.getStringExtra("forGesture") ?: return - Gesture.byId(forGesture)?.let { Action.setActionForGesture(it, Action.fromIntent(data)) } -} - - // used for the bug report button fun getDeviceInfo(): String { return """ @@ -216,4 +226,4 @@ fun copyToClipboard(context: Context, text: String) { val clipboardManager = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager val clipData = ClipData.newPlainText("Debug Info", text) clipboardManager.setPrimaryClip(clipData) -} \ No newline at end of file +} diff --git a/app/src/main/java/de/jrpie/android/launcher/actions/Action.kt b/app/src/main/java/de/jrpie/android/launcher/actions/Action.kt index ddef92a..a883922 100644 --- a/app/src/main/java/de/jrpie/android/launcher/actions/Action.kt +++ b/app/src/main/java/de/jrpie/android/launcher/actions/Action.kt @@ -2,11 +2,11 @@ package de.jrpie.android.launcher.actions import android.app.Activity import android.content.Context -import android.content.Intent import android.content.SharedPreferences.Editor import android.graphics.Rect import android.graphics.drawable.Drawable import android.widget.Toast +import androidx.core.content.edit import de.jrpie.android.launcher.R import de.jrpie.android.launcher.preferences.LauncherPreferences import kotlinx.serialization.Serializable @@ -14,6 +14,10 @@ import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json +/** + * Represents an action that can be bound to a [Gesture]. + * There are four types of actions: [AppAction], [ShortcutAction], [LauncherAction] and [WidgetPanelAction] + */ @Serializable sealed interface Action { fun invoke(context: Context, rect: Rect? = null): Boolean @@ -21,6 +25,10 @@ sealed interface Action { fun getIcon(context: Context): Drawable? fun isAvailable(context: Context): Boolean + fun showConfigurationDialog(context: Context, onSuccess: (Action) -> Unit) { + onSuccess(this) + } + // Can the action be used to reach µLauncher settings? fun canReachSettings(): Boolean @@ -29,10 +37,6 @@ sealed interface Action { prefEditor.putString(id, Json.encodeToString(this)) } - fun writeToIntent(intent: Intent) { - intent.putExtra("action", Json.encodeToString(this)) - } - companion object { fun forGesture(gesture: Gesture): Action? { @@ -44,23 +48,23 @@ sealed interface Action { } fun resetToDefaultActions(context: Context) { - val editor = LauncherPreferences.getSharedPreferences().edit() - val boundActions = HashSet() - Gesture.entries.forEach { gesture -> - context.resources - .getStringArray(gesture.defaultsResource) - .filterNot { boundActions.contains(it) } - .map { Pair(it, Json.decodeFromString(it)) } - .firstOrNull { it.second.isAvailable(context) } - ?.apply { - // allow to bind CHOOSE to multiple gestures - if (second != LauncherAction.CHOOSE) { - boundActions.add(first) + LauncherPreferences.getSharedPreferences().edit { + val boundActions = HashSet() + Gesture.entries.forEach { gesture -> + context.resources + .getStringArray(gesture.defaultsResource) + .filterNot { boundActions.contains(it) } + .map { Pair(it, Json.decodeFromString(it)) } + .firstOrNull { it.second.isAvailable(context) } + ?.apply { + // allow to bind CHOOSE to multiple gestures + if (second != LauncherAction.CHOOSE) { + boundActions.add(first) + } + second.bindToGesture(this@edit, gesture.id) } - second.bindToGesture(editor, gesture.id) - } + } } - editor.apply() } fun setActionForGesture(gesture: Gesture, action: Action?) { @@ -68,15 +72,15 @@ sealed interface Action { clearActionForGesture(gesture) return } - val editor = LauncherPreferences.getSharedPreferences().edit() - action.bindToGesture(editor, gesture.id) - editor.apply() + LauncherPreferences.getSharedPreferences().edit { + action.bindToGesture(this, gesture.id) + } } fun clearActionForGesture(gesture: Gesture) { - LauncherPreferences.getSharedPreferences().edit() - .remove(gesture.id) - .apply() + LauncherPreferences.getSharedPreferences().edit { + remove(gesture.id) + } } fun launch( @@ -87,6 +91,9 @@ sealed interface Action { ) { if (action != null && action.invoke(context)) { if (context is Activity) { + // There does not seem to be a good alternative to overridePendingTransition. + // Note that we can't use overrideActivityTransition here. + @Suppress("deprecation") context.overridePendingTransition(animationIn, animationOut) } } else { @@ -97,10 +104,5 @@ sealed interface Action { ).show() } } - - fun fromIntent(data: Intent): Action? { - val json = data.getStringExtra("action") ?: return null - return Json.decodeFromString(json) - } } } \ No newline at end of file diff --git a/app/src/main/java/de/jrpie/android/launcher/actions/AppAction.kt b/app/src/main/java/de/jrpie/android/launcher/actions/AppAction.kt index 90145aa..1446b13 100644 --- a/app/src/main/java/de/jrpie/android/launcher/actions/AppAction.kt +++ b/app/src/main/java/de/jrpie/android/launcher/actions/AppAction.kt @@ -11,7 +11,7 @@ import android.graphics.drawable.Drawable import android.util.Log import de.jrpie.android.launcher.R import de.jrpie.android.launcher.apps.AppInfo -import de.jrpie.android.launcher.apps.AppInfo.Companion.INVALID_USER +import de.jrpie.android.launcher.apps.AbstractAppInfo.Companion.INVALID_USER import de.jrpie.android.launcher.apps.DetailedAppInfo import de.jrpie.android.launcher.ui.list.apps.openSettings import kotlinx.serialization.SerialName @@ -67,7 +67,7 @@ class AppAction(val app: AppInfo) : Action { } override fun getIcon(context: Context): Drawable? { - return DetailedAppInfo.fromAppInfo(app, context)?.icon + return DetailedAppInfo.fromAppInfo(app, context)?.getIcon(context) } override fun isAvailable(context: Context): Boolean { diff --git a/app/src/main/java/de/jrpie/android/launcher/actions/Gesture.kt b/app/src/main/java/de/jrpie/android/launcher/actions/Gesture.kt index 110e4f8..a2434e1 100644 --- a/app/src/main/java/de/jrpie/android/launcher/actions/Gesture.kt +++ b/app/src/main/java/de/jrpie/android/launcher/actions/Gesture.kt @@ -250,7 +250,7 @@ enum class Gesture( "action.back", R.string.settings_gesture_back, R.string.settings_gesture_description_back, - R.array.default_up + R.array.default_back ); enum class Edge { diff --git a/app/src/main/java/de/jrpie/android/launcher/actions/LauncherAction.kt b/app/src/main/java/de/jrpie/android/launcher/actions/LauncherAction.kt index 1ed6473..6ba467e 100644 --- a/app/src/main/java/de/jrpie/android/launcher/actions/LauncherAction.kt +++ b/app/src/main/java/de/jrpie/android/launcher/actions/LauncherAction.kt @@ -11,8 +11,11 @@ import android.view.KeyEvent import android.widget.Toast import androidx.appcompat.content.res.AppCompatResources import de.jrpie.android.launcher.Application +import de.jrpie.android.launcher.BuildConfig import de.jrpie.android.launcher.R +import de.jrpie.android.launcher.actions.lock.LauncherAccessibilityService import de.jrpie.android.launcher.apps.AppFilter +import de.jrpie.android.launcher.apps.hidePrivateSpaceWhenLocked import de.jrpie.android.launcher.apps.isPrivateSpaceSupported import de.jrpie.android.launcher.apps.togglePrivateSpaceLock import de.jrpie.android.launcher.preferences.LauncherPreferences @@ -66,7 +69,11 @@ enum class LauncherAction( R.string.list_other_list_private_space, R.drawable.baseline_security_24, { context -> - openAppsList(context, private = true) + if ((context.applicationContext as Application).privateSpaceLocked.value != true + || !hidePrivateSpaceWhenLocked(context) + ) { + openAppsList(context, private = true) + } }, available = { _ -> isPrivateSpaceSupported() @@ -83,31 +90,37 @@ enum class LauncherAction( "volume_up", R.string.list_other_volume_up, R.drawable.baseline_volume_up_24, - { context -> audioVolumeAdjust(context, true)} + { context -> audioVolumeAdjust(context, AudioManager.ADJUST_RAISE) } ), VOLUME_DOWN( "volume_down", R.string.list_other_volume_down, R.drawable.baseline_volume_down_24, - { context -> audioVolumeAdjust(context, false)} + { context -> audioVolumeAdjust(context, AudioManager.ADJUST_LOWER) } + ), + VOLUME_ADJUST( + "volume_adjust", + R.string.list_other_volume_adjust, + R.drawable.baseline_volume_adjust_24, + { context -> audioVolumeAdjust(context, AudioManager.ADJUST_SAME) } ), TRACK_PLAY_PAUSE( "play_pause_track", R.string.list_other_track_play_pause, R.drawable.baseline_play_arrow_24, - { context -> audioManagerPressKey(context, KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE)} + { context -> audioManagerPressKey(context, KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE) } ), TRACK_NEXT( "next_track", R.string.list_other_track_next, R.drawable.baseline_skip_next_24, - { context -> audioManagerPressKey(context, KeyEvent.KEYCODE_MEDIA_NEXT)} + { context -> audioManagerPressKey(context, KeyEvent.KEYCODE_MEDIA_NEXT) } ), TRACK_PREV( "previous_track", R.string.list_other_track_previous, R.drawable.baseline_skip_previous_24, - { context -> audioManagerPressKey(context, KeyEvent.KEYCODE_MEDIA_PREVIOUS)} + { context -> audioManagerPressKey(context, KeyEvent.KEYCODE_MEDIA_PREVIOUS) } ), EXPAND_NOTIFICATIONS_PANEL( "expand_notifications_panel", @@ -121,6 +134,14 @@ enum class LauncherAction( R.drawable.baseline_settings_applications_24, ::expandSettingsPanel ), + RECENT_APPS( + "recent_apps", + R.string.list_other_recent_apps, + R.drawable.baseline_apps_24, + LauncherAccessibilityService::openRecentApps, + false, + { _ -> BuildConfig.USE_ACCESSIBILITY_SERVICE } + ), LOCK_SCREEN( "lock_screen", R.string.list_other_lock_screen, @@ -131,7 +152,13 @@ enum class LauncherAction( "toggle_torch", R.string.list_other_torch, R.drawable.baseline_flashlight_on_24, - ::toggleTorch + ::toggleTorch, + ), + LAUNCH_OTHER_LAUNCHER( + "launcher_other_launcher", + R.string.list_other_launch_other_launcher, + R.drawable.baseline_home_24, + ::launchOtherLauncher ), NOP("nop", R.string.list_other_nop, R.drawable.baseline_not_interested_24, {}); @@ -176,17 +203,13 @@ private fun audioManagerPressKey(context: Context, key: Int) { } -private fun audioVolumeAdjust(context: Context, louder: Boolean) { +private fun audioVolumeAdjust(context: Context, direction: Int) { val audioManager = context.getSystemService(Context.AUDIO_SERVICE) as AudioManager audioManager.adjustStreamVolume( AudioManager.STREAM_MUSIC, - if (louder) { - AudioManager.ADJUST_RAISE - } else { - AudioManager.ADJUST_LOWER - }, + direction, AudioManager.FLAG_SHOW_UI ) } @@ -241,6 +264,15 @@ private fun expandSettingsPanel(context: Context) { } } +private fun launchOtherLauncher(context: Context) { + context.startActivity( + Intent.createChooser( + Intent(Intent.ACTION_MAIN).also { it.addCategory(Intent.CATEGORY_HOME) }, + context.getString(R.string.list_other_launch_other_launcher) + ) + ) +} + private fun openSettings(context: Context) { context.startActivity(Intent(context, SettingsActivity::class.java)) } diff --git a/app/src/main/java/de/jrpie/android/launcher/actions/ShortcutAction.kt b/app/src/main/java/de/jrpie/android/launcher/actions/ShortcutAction.kt index 8517b1a..a89f9e2 100644 --- a/app/src/main/java/de/jrpie/android/launcher/actions/ShortcutAction.kt +++ b/app/src/main/java/de/jrpie/android/launcher/actions/ShortcutAction.kt @@ -6,7 +6,7 @@ import android.content.pm.LauncherApps import android.graphics.Rect import android.graphics.drawable.Drawable import android.os.Build -import de.jrpie.android.launcher.actions.shortcuts.PinnedShortcutInfo +import de.jrpie.android.launcher.apps.PinnedShortcutInfo import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable diff --git a/app/src/main/java/de/jrpie/android/launcher/actions/WidgetPanelAction.kt b/app/src/main/java/de/jrpie/android/launcher/actions/WidgetPanelAction.kt new file mode 100644 index 0000000..d7829a6 --- /dev/null +++ b/app/src/main/java/de/jrpie/android/launcher/actions/WidgetPanelAction.kt @@ -0,0 +1,83 @@ +package de.jrpie.android.launcher.actions + +import android.content.Context +import android.content.Intent +import android.graphics.Rect +import android.graphics.drawable.Drawable +import android.view.View +import android.widget.TextView +import android.widget.Toast +import androidx.appcompat.app.AlertDialog +import androidx.core.content.res.ResourcesCompat +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import de.jrpie.android.launcher.R +import de.jrpie.android.launcher.ui.widgets.WidgetPanelActivity +import de.jrpie.android.launcher.ui.widgets.manage.EXTRA_PANEL_ID +import de.jrpie.android.launcher.ui.widgets.manage.WidgetPanelsRecyclerAdapter +import de.jrpie.android.launcher.widgets.WidgetPanel +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +@SerialName("action:panel") +class WidgetPanelAction(val widgetPanelId: Int) : Action { + + override fun invoke(context: Context, rect: Rect?): Boolean { + + if (WidgetPanel.byId(widgetPanelId) == null) { + Toast.makeText(context, R.string.alert_widget_panel_not_found, Toast.LENGTH_LONG).show() + } else { + context.startActivity(Intent(context, WidgetPanelActivity::class.java).also { + it.putExtra(EXTRA_PANEL_ID, widgetPanelId) + }) + } + return true + } + + override fun label(context: Context): String { + return WidgetPanel.byId(widgetPanelId)?.label + ?: context.getString(R.string.list_other_open_widget_panel) + } + + override fun isAvailable(context: Context): Boolean { + return true + } + + override fun canReachSettings(): Boolean { + return false + } + + override fun getIcon(context: Context): Drawable? { + return ResourcesCompat.getDrawable( + context.resources, + R.drawable.baseline_widgets_24, + context.theme + ) + } + + override fun showConfigurationDialog(context: Context, onSuccess: (Action) -> Unit) { + AlertDialog.Builder(context, R.style.AlertDialogCustom).apply { + setTitle(R.string.dialog_select_widget_panel_title) + setNegativeButton(R.string.dialog_cancel) { _, _ -> } + setView(R.layout.dialog_select_widget_panel) + }.create().also { it.show() }.also { alertDialog -> + val infoTextView = + alertDialog.findViewById(R.id.dialog_select_widget_panel_info) + alertDialog.findViewById(R.id.dialog_select_widget_panel_recycler) + ?.apply { + setHasFixedSize(true) + layoutManager = LinearLayoutManager(alertDialog.context) + adapter = + WidgetPanelsRecyclerAdapter(alertDialog.context, false) { widgetPanel -> + onSuccess(WidgetPanelAction(widgetPanel.id)) + alertDialog.dismiss() + } + if (adapter?.itemCount == 0) { + infoTextView?.visibility = View.VISIBLE + } + } + } + true + } +} diff --git a/app/src/main/java/de/jrpie/android/launcher/actions/lock/LauncherAccessibilityService.kt b/app/src/main/java/de/jrpie/android/launcher/actions/lock/LauncherAccessibilityService.kt index a8ef6f2..7cb32d9 100644 --- a/app/src/main/java/de/jrpie/android/launcher/actions/lock/LauncherAccessibilityService.kt +++ b/app/src/main/java/de/jrpie/android/launcher/actions/lock/LauncherAccessibilityService.kt @@ -22,26 +22,44 @@ class LauncherAccessibilityService : AccessibilityService() { companion object { private const val TAG = "Launcher Accessibility" + private const val ACTION_REQUEST_ENABLE = "ACTION_REQUEST_ENABLE" const val ACTION_LOCK_SCREEN = "ACTION_LOCK_SCREEN" + const val ACTION_RECENT_APPS = "ACTION_RECENT_APPS" - fun lockScreen(context: Context) { + private fun invoke(context: Context, action: String, failureMessageRes: Int) { try { context.startService( Intent( context, LauncherAccessibilityService::class.java ).apply { - action = ACTION_LOCK_SCREEN + this.action = action }) - } catch (e: Exception) { + } catch (_: Exception) { Toast.makeText( context, - context.getString(R.string.alert_lock_screen_failed), + context.getString(failureMessageRes), Toast.LENGTH_LONG ).show() } } + fun lockScreen(context: Context) { + if (!isEnabled(context)) { + showEnableDialog(context) + } else { + invoke(context, ACTION_LOCK_SCREEN, R.string.alert_lock_screen_failed) + } + } + + fun openRecentApps(context: Context) { + if (!isEnabled(context)) { + showEnableDialog(context) + } else { + invoke(context, ACTION_RECENT_APPS, R.string.alert_recent_apps_failed) + } + } + fun isEnabled(context: Context): Boolean { val enabledServices = Settings.Secure.getString( context.contentResolver, @@ -58,7 +76,7 @@ class LauncherAccessibilityService : AccessibilityService() { setView(R.layout.dialog_consent_accessibility) setTitle(R.string.dialog_consent_accessibility_title) setPositiveButton(R.string.dialog_consent_accessibility_ok) { _, _ -> - lockScreen(context) + invoke(context, ACTION_REQUEST_ENABLE, R.string.alert_enable_accessibility_failed) } setNegativeButton(R.string.dialog_cancel) { _, _ -> } }.create().also { it.show() }.apply { @@ -94,7 +112,9 @@ class LauncherAccessibilityService : AccessibilityService() { } when (action) { + ACTION_REQUEST_ENABLE -> {} // do nothing ACTION_LOCK_SCREEN -> handleLockScreen() + ACTION_RECENT_APPS -> performGlobalAction(GLOBAL_ACTION_RECENTS) } } return super.onStartCommand(intent, flags, startId) diff --git a/app/src/main/java/de/jrpie/android/launcher/actions/lock/LockMethod.kt b/app/src/main/java/de/jrpie/android/launcher/actions/lock/LockMethod.kt index 8ae2415..93b4cbf 100644 --- a/app/src/main/java/de/jrpie/android/launcher/actions/lock/LockMethod.kt +++ b/app/src/main/java/de/jrpie/android/launcher/actions/lock/LockMethod.kt @@ -6,10 +6,10 @@ import android.widget.Button import androidx.appcompat.app.AlertDialog import de.jrpie.android.launcher.BuildConfig import de.jrpie.android.launcher.R +import de.jrpie.android.launcher.actions.lock.LauncherAccessibilityService import de.jrpie.android.launcher.preferences.LauncherPreferences -@Suppress("unused") enum class LockMethod( private val lock: (Context) -> Unit, private val isEnabled: (Context) -> Boolean, diff --git a/app/src/main/java/de/jrpie/android/launcher/actions/shortcuts/PinnedShortcutInfo.kt b/app/src/main/java/de/jrpie/android/launcher/actions/shortcuts/PinnedShortcutInfo.kt deleted file mode 100644 index 796c737..0000000 --- a/app/src/main/java/de/jrpie/android/launcher/actions/shortcuts/PinnedShortcutInfo.kt +++ /dev/null @@ -1,60 +0,0 @@ -package de.jrpie.android.launcher.actions.shortcuts - -import android.app.Service -import android.content.ComponentName -import android.content.Context -import android.content.pm.LauncherApps -import android.content.pm.LauncherApps.ShortcutQuery -import android.content.pm.ShortcutInfo -import android.os.Build -import androidx.annotation.RequiresApi -import de.jrpie.android.launcher.getUserFromId -import kotlinx.serialization.Serializable - - -@RequiresApi(Build.VERSION_CODES.N_MR1) -@Serializable -class PinnedShortcutInfo( - val id: String, - val packageName: String, - val activityName: String, - val user: Int -) { - - constructor(info: ShortcutInfo) : this(info.id, info.`package`, info.activity?.className ?: "", info.userHandle.hashCode()) - - fun getShortcutInfo(context: Context): ShortcutInfo? { - val launcherApps = context.getSystemService(Service.LAUNCHER_APPS_SERVICE) as LauncherApps - - return launcherApps.getShortcuts( - ShortcutQuery().apply { - setQueryFlags(ShortcutQuery.FLAG_MATCH_PINNED) - setPackage(packageName) - setActivity(ComponentName(packageName, activityName)) - setShortcutIds(listOf(id)) - }, - getUserFromId(user, context) - )?.firstOrNull() - } - - override fun equals(other: Any?): Boolean { - return (other as? PinnedShortcutInfo)?.let { - packageName == this.packageName && - activityName == this.activityName && - id == this.id && - user == this.user - } ?: false - } - - override fun hashCode(): Int { - var result = id.hashCode() - result = 31 * result + packageName.hashCode() - result = 31 * result + activityName.hashCode() - result = 31 * result + user - return result - } - - override fun toString(): String { - return "PinnedShortcutInfo { package=$packageName, activity=$activityName, user=$user, id=$id}" - } -} \ No newline at end of file diff --git a/app/src/main/java/de/jrpie/android/launcher/apps/AbstractAppInfo.kt b/app/src/main/java/de/jrpie/android/launcher/apps/AbstractAppInfo.kt new file mode 100644 index 0000000..dd60752 --- /dev/null +++ b/app/src/main/java/de/jrpie/android/launcher/apps/AbstractAppInfo.kt @@ -0,0 +1,22 @@ +package de.jrpie.android.launcher.apps + +import kotlinx.serialization.Serializable +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json + +/** + * This interface is implemented by [AppInfo] and [PinnedShortcutInfo]. + */ +@Serializable +sealed interface AbstractAppInfo { + fun serialize(): String { + return Json.encodeToString(this) + } + companion object { + const val INVALID_USER = -1 + + fun deserialize(serialized: String): AbstractAppInfo { + return Json.decodeFromString(serialized) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/de/jrpie/android/launcher/apps/AbstractDetailedAppInfo.kt b/app/src/main/java/de/jrpie/android/launcher/apps/AbstractDetailedAppInfo.kt new file mode 100644 index 0000000..9c7413d --- /dev/null +++ b/app/src/main/java/de/jrpie/android/launcher/apps/AbstractDetailedAppInfo.kt @@ -0,0 +1,42 @@ +package de.jrpie.android.launcher.apps + +import android.content.Context +import android.graphics.drawable.Drawable +import android.os.UserHandle +import android.util.Log +import de.jrpie.android.launcher.Application +import de.jrpie.android.launcher.actions.Action +import de.jrpie.android.launcher.preferences.LauncherPreferences + +/** + * This interface is implemented by [DetailedAppInfo] and [DetailedPinnedShortcutInfo] + */ +sealed interface AbstractDetailedAppInfo { + fun getRawInfo(): AbstractAppInfo + fun getLabel(): String + fun getIcon(context: Context): Drawable + fun getUser(context: Context): UserHandle + fun isPrivate(): Boolean + fun isRemovable(): Boolean + fun getAction(): Action + + + fun getCustomLabel(context: Context): String { + val map = (context.applicationContext as? Application)?.getCustomAppNames() + return map?.get(getRawInfo()) ?: getLabel() + } + + + fun setCustomLabel(label: CharSequence?) { + Log.i("Launcher", "Setting custom label for ${this.getRawInfo()} to ${label}.") + val map = LauncherPreferences.apps().customNames() ?: HashMap() + + if (label.isNullOrEmpty()) { + map.remove(getRawInfo()) + } else { + map[getRawInfo()] = label.toString() + } + LauncherPreferences.apps().customNames(map) + } + +} \ No newline at end of file diff --git a/app/src/main/java/de/jrpie/android/launcher/apps/AppFilter.kt b/app/src/main/java/de/jrpie/android/launcher/apps/AppFilter.kt index ecc7eaa..ca387c0 100644 --- a/app/src/main/java/de/jrpie/android/launcher/apps/AppFilter.kt +++ b/app/src/main/java/de/jrpie/android/launcher/apps/AppFilter.kt @@ -6,6 +6,7 @@ import android.os.Build import de.jrpie.android.launcher.actions.Action import de.jrpie.android.launcher.actions.AppAction import de.jrpie.android.launcher.actions.Gesture +import de.jrpie.android.launcher.actions.ShortcutAction import de.jrpie.android.launcher.preferences.LauncherPreferences import java.util.Locale import kotlin.text.Regex.Companion.escape @@ -18,13 +19,14 @@ class AppFilter( var privateSpaceVisibility: AppSetVisibility = AppSetVisibility.VISIBLE ) { - operator fun invoke(apps: List): List { + operator fun invoke(apps: List): List { var apps = - apps.sortedBy { app -> app.getCustomLabel(context).toString().lowercase(Locale.ROOT) } + apps.sortedBy { app -> app.getCustomLabel(context).lowercase(Locale.ROOT) } val hidden = LauncherPreferences.apps().hidden() ?: setOf() val favorites = LauncherPreferences.apps().favorites() ?: setOf() - val private = apps.filter { it.isPrivateSpaceApp }.map { it.app }.toSet() + val private = apps.filter { it.isPrivate() } + .map { it.getRawInfo() }.toSet() apps = apps.filter { info -> favoritesVisibility.predicate(favorites, info) @@ -35,9 +37,13 @@ class AppFilter( if (LauncherPreferences.apps().hideBoundApps()) { val boundApps = Gesture.entries .filter(Gesture::isEnabled) - .mapNotNull { g -> (Action.forGesture(g) as? AppAction)?.app } + .mapNotNull { g -> Action.forGesture(g) } + .mapNotNull { + (it as? AppAction)?.app + ?: (it as? ShortcutAction)?.shortcut + } .toSet() - apps = apps.filterNot { info -> boundApps.contains(info.app) } + apps = apps.filterNot { info -> boundApps.contains(info.getRawInfo()) } } // normalize text for search @@ -57,11 +63,11 @@ class AppFilter( if (query.isEmpty()) { return apps } else { - val r: MutableList = ArrayList() - val appsSecondary: MutableList = ArrayList() + val r: MutableList = ArrayList() + val appsSecondary: MutableList = ArrayList() val normalizedQuery: String = normalize(query) for (item in apps) { - val itemLabel: String = normalize(item.getCustomLabel(context).toString()) + val itemLabel: String = normalize(item.getCustomLabel(context)) if (itemLabel.startsWith(normalizedQuery)) { r.add(item) @@ -77,11 +83,11 @@ class AppFilter( companion object { enum class AppSetVisibility( - val predicate: (set: Set, DetailedAppInfo) -> Boolean + val predicate: (set: Set, AbstractDetailedAppInfo) -> Boolean ) { VISIBLE({ _, _ -> true }), - HIDDEN({ set, appInfo -> !set.contains(appInfo.app) }), - EXCLUSIVE({ set, appInfo -> set.contains(appInfo.app) }), + HIDDEN({ set, appInfo -> !set.contains(appInfo.getRawInfo()) }), + EXCLUSIVE({ set, appInfo -> set.contains(appInfo.getRawInfo()) }), ; } diff --git a/app/src/main/java/de/jrpie/android/launcher/apps/AppInfo.kt b/app/src/main/java/de/jrpie/android/launcher/apps/AppInfo.kt index 21614f8..9534431 100644 --- a/app/src/main/java/de/jrpie/android/launcher/apps/AppInfo.kt +++ b/app/src/main/java/de/jrpie/android/launcher/apps/AppInfo.kt @@ -4,33 +4,18 @@ import android.app.Service import android.content.Context import android.content.pm.LauncherActivityInfo import android.content.pm.LauncherApps +import de.jrpie.android.launcher.apps.AbstractAppInfo.Companion.INVALID_USER import de.jrpie.android.launcher.getUserFromId +import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable -import kotlinx.serialization.encodeToString -import kotlinx.serialization.json.Json /** * Represents an app installed on the users device. * Contains the minimal amount of data required to identify the app. */ @Serializable -class AppInfo(val packageName: String, val activityName: String?, val user: Int = INVALID_USER) { - - fun serialize(): String { - return Json.encodeToString(this) - } - - override fun equals(other: Any?): Boolean { - if(other is AppInfo) { - return other.user == user && other.packageName == packageName - && other.activityName == activityName - } - return super.equals(other) - } - - override fun hashCode(): Int { - return packageName.hashCode() - } +@SerialName("app") +data class AppInfo(val packageName: String, val activityName: String?, val user: Int = INVALID_USER): AbstractAppInfo { fun getLauncherActivityInfo( context: Context @@ -41,17 +26,4 @@ class AppInfo(val packageName: String, val activityName: String?, val user: Int return activityList.firstOrNull { app -> app.name == activityName } ?: activityList.firstOrNull() } - - - override fun toString(): String { - return "AppInfo {package=$packageName, activity=$activityName, user=$user}" - } - - companion object { - const val INVALID_USER = -1 - - fun deserialize(serialized: String): AppInfo { - return Json.decodeFromString(serialized) - } - } } \ No newline at end of file diff --git a/app/src/main/java/de/jrpie/android/launcher/apps/DetailedAppInfo.kt b/app/src/main/java/de/jrpie/android/launcher/apps/DetailedAppInfo.kt index d77bf93..76f7fbb 100644 --- a/app/src/main/java/de/jrpie/android/launcher/apps/DetailedAppInfo.kt +++ b/app/src/main/java/de/jrpie/android/launcher/apps/DetailedAppInfo.kt @@ -4,20 +4,21 @@ import android.content.Context import android.content.pm.ApplicationInfo import android.content.pm.LauncherActivityInfo import android.graphics.drawable.Drawable -import android.util.Log -import de.jrpie.android.launcher.Application -import de.jrpie.android.launcher.preferences.LauncherPreferences +import android.os.UserHandle +import de.jrpie.android.launcher.actions.Action +import de.jrpie.android.launcher.actions.AppAction +import de.jrpie.android.launcher.getUserFromId /** * Stores information used to create [de.jrpie.android.launcher.ui.list.apps.AppsRecyclerAdapter] rows. */ class DetailedAppInfo( - val app: AppInfo, - val label: CharSequence, - val icon: Drawable, - val isPrivateSpaceApp: Boolean, - val isSystemApp: Boolean = false, -) { + private val app: AppInfo, + private val label: CharSequence, + private val icon: Drawable, + private val privateSpace: Boolean, + private val removable: Boolean = true, +): AbstractDetailedAppInfo { constructor(activityInfo: LauncherActivityInfo, private: Boolean) : this( AppInfo( @@ -28,29 +29,41 @@ class DetailedAppInfo( activityInfo.label, activityInfo.getBadgedIcon(0), private, - activityInfo.applicationInfo.flags.and(ApplicationInfo.FLAG_SYSTEM) != 0 + // App can be uninstalled iff it is not a system app + activityInfo.applicationInfo.flags.and(ApplicationInfo.FLAG_SYSTEM) == 0 ) - fun getCustomLabel(context: Context): CharSequence { - val map = (context.applicationContext as? Application)?.getCustomAppNames() ?: return label - return map[app] ?: label + + override fun getLabel(): String { + return label.toString() } - fun setCustomLabel(label: CharSequence?) { - - Log.i("Launcher", "Setting custom label for ${this.app} to ${label}.") - val map = LauncherPreferences.apps().customNames() ?: HashMap() - - if (label.isNullOrEmpty()) { - map.remove(app) - } else { - map[app] = label.toString() - } - - LauncherPreferences.apps().customNames(map) + override fun getIcon(context: Context): Drawable { + return icon } + override fun getRawInfo(): AppInfo { + return app + } + + override fun getUser(context: Context): UserHandle { + return getUserFromId(app.user, context) + } + + override fun isPrivate(): Boolean { + return privateSpace + } + + override fun isRemovable(): Boolean { + return removable + } + + override fun getAction(): Action { + return AppAction(app) + } + + companion object { fun fromAppInfo(appInfo: AppInfo, context: Context): DetailedAppInfo? { return appInfo.getLauncherActivityInfo(context)?.let { diff --git a/app/src/main/java/de/jrpie/android/launcher/apps/DetailedPinnedShortcutInfo.kt b/app/src/main/java/de/jrpie/android/launcher/apps/DetailedPinnedShortcutInfo.kt new file mode 100644 index 0000000..f66034d --- /dev/null +++ b/app/src/main/java/de/jrpie/android/launcher/apps/DetailedPinnedShortcutInfo.kt @@ -0,0 +1,66 @@ +package de.jrpie.android.launcher.apps + +import android.app.Service +import android.content.Context +import android.content.pm.LauncherApps +import android.content.pm.ShortcutInfo +import android.graphics.drawable.Drawable +import android.os.Build +import android.os.UserHandle +import androidx.annotation.RequiresApi +import de.jrpie.android.launcher.actions.Action +import de.jrpie.android.launcher.actions.ShortcutAction +import de.jrpie.android.launcher.getUserFromId + +@RequiresApi(Build.VERSION_CODES.N_MR1) +class DetailedPinnedShortcutInfo( + private val shortcutInfo: PinnedShortcutInfo, + private val label: String, + private val icon: Drawable, + private val privateSpace: Boolean +) : AbstractDetailedAppInfo { + + constructor(context: Context, shortcut: ShortcutInfo) : this( + PinnedShortcutInfo(shortcut), + shortcut.longLabel.toString(), + (context.getSystemService(Service.LAUNCHER_APPS_SERVICE) as LauncherApps) + .getShortcutBadgedIconDrawable(shortcut, 0), + shortcut.userHandle == getPrivateSpaceUser(context) + ) + + override fun getRawInfo(): AbstractAppInfo { + return shortcutInfo + } + + override fun getLabel(): String { + return label + } + + override fun getIcon(context: Context): Drawable { + return icon + } + + override fun getUser(context: Context): UserHandle { + return getUserFromId(shortcutInfo.user, context) + } + + override fun isPrivate(): Boolean { + return privateSpace + } + + override fun isRemovable(): Boolean { + return true + } + + override fun getAction(): Action { + return ShortcutAction(shortcutInfo) + } + + companion object { + fun fromPinnedShortcutInfo(shortcutInfo: PinnedShortcutInfo, context: Context): DetailedPinnedShortcutInfo? { + return shortcutInfo.getShortcutInfo(context)?.let { + DetailedPinnedShortcutInfo(context, it) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/de/jrpie/android/launcher/apps/PinnedShortcutInfo.kt b/app/src/main/java/de/jrpie/android/launcher/apps/PinnedShortcutInfo.kt new file mode 100644 index 0000000..54230ae --- /dev/null +++ b/app/src/main/java/de/jrpie/android/launcher/apps/PinnedShortcutInfo.kt @@ -0,0 +1,46 @@ +package de.jrpie.android.launcher.apps + +import android.app.Service +import android.content.ComponentName +import android.content.Context +import android.content.pm.LauncherApps +import android.content.pm.LauncherApps.ShortcutQuery +import android.content.pm.ShortcutInfo +import android.os.Build +import androidx.annotation.RequiresApi +import de.jrpie.android.launcher.getUserFromId +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + + +@RequiresApi(Build.VERSION_CODES.N_MR1) +@Serializable +@SerialName("shortcut") +data class PinnedShortcutInfo( + val id: String, + val packageName: String, + val activityName: String, + val user: Int +): AbstractAppInfo { + + constructor(info: ShortcutInfo) : this(info.id, info.`package`, info.activity?.className ?: "", info.userHandle.hashCode()) + + fun getShortcutInfo(context: Context): ShortcutInfo? { + val launcherApps = context.getSystemService(Service.LAUNCHER_APPS_SERVICE) as LauncherApps + + return try { + launcherApps.getShortcuts( + ShortcutQuery().apply { + setQueryFlags(ShortcutQuery.FLAG_MATCH_PINNED) + setPackage(packageName) + setActivity(ComponentName(packageName, activityName)) + setShortcutIds(listOf(id)) + }, + getUserFromId(user, context) + )?.firstOrNull() + } catch(_: Exception) { + // can throw SecurityException or IllegalStateException when profile is locked + null + } + } +} \ No newline at end of file diff --git a/app/src/main/java/de/jrpie/android/launcher/apps/PrivateSpace.kt b/app/src/main/java/de/jrpie/android/launcher/apps/PrivateSpace.kt index 9b37d60..24665d7 100644 --- a/app/src/main/java/de/jrpie/android/launcher/apps/PrivateSpace.kt +++ b/app/src/main/java/de/jrpie/android/launcher/apps/PrivateSpace.kt @@ -91,10 +91,17 @@ fun isPrivateSpaceLocked(context: Context): Boolean { val privateSpaceUser = getPrivateSpaceUser(context) ?: return false return userManager.isQuietModeEnabled(privateSpaceUser) } + fun lockPrivateSpace(context: Context, lock: Boolean) { if (!isPrivateSpaceSupported()) { return } + + // silently return when trying to unlock but hide when locked is set + if (!lock && hidePrivateSpaceWhenLocked(context)) { + return + } + val userManager = context.getSystemService(Context.USER_SERVICE) as UserManager val privateSpaceUser = getPrivateSpaceUser(context) ?: return userManager.requestQuietModeEnabled(lock, privateSpaceUser) @@ -116,3 +123,18 @@ fun togglePrivateSpaceLock(context: Context) { } } +@Suppress("SameReturnValue") +fun hidePrivateSpaceWhenLocked(context: Context): Boolean { + // Trying to access the setting as a 3rd party launcher raises a security exception. + // This is an Android bug: https://issuetracker.google.com/issues/352276244#comment5 + // The logic for this is implemented. + // TODO: replace this once the Android bug is fixed + return false + + // TODO: perhaps this should be cached + // https://cs.android.com/android/platform/superproject/main/+/main:packages/apps/Launcher3/src/com/android/launcher3/util/SettingsCache.java;l=61;drc=56bf7ad33bc9d5ed3c18e7abefeec5c177ec75d7 + + // val key = "hide_privatespace_entry_point" + // return Settings.Secure.getInt(context.contentResolver, key, 0) == 1 +} + diff --git a/app/src/main/java/de/jrpie/android/launcher/preferences/ColorPreference.kt b/app/src/main/java/de/jrpie/android/launcher/preferences/ColorPreference.kt index a21d458..0f95efd 100644 --- a/app/src/main/java/de/jrpie/android/launcher/preferences/ColorPreference.kt +++ b/app/src/main/java/de/jrpie/android/launcher/preferences/ColorPreference.kt @@ -17,6 +17,7 @@ import androidx.core.graphics.green import androidx.core.graphics.red import androidx.preference.Preference import de.jrpie.android.launcher.R +import androidx.core.graphics.toColorInt class ColorPreference(context: Context, attrs: AttributeSet?) : Preference(context, attrs) { @@ -52,7 +53,7 @@ class ColorPreference(context: Context, attrs: AttributeSet?) : AlertDialog.Builder(context, R.style.AlertDialogCustom).apply { setView(R.layout.dialog_choose_color) setTitle(R.string.dialog_choose_color_title) - setPositiveButton(R.string.dialog_select_color_ok) { _, _ -> + setPositiveButton(android.R.string.ok) { _, _ -> persistInt(currentColor) summary = currentColor.getHex() } @@ -83,10 +84,10 @@ class ColorPreference(context: Context, attrs: AttributeSet?) : override fun onTextChanged(text: CharSequence?, p1: Int, p2: Int, p3: Int) {} override fun afterTextChanged(editable: Editable?) { preview.hasFocus() || return - val newText = editable?.toString() - newText.isNullOrBlank() && return + val newText = editable?.toString() ?: return + newText.isBlank() && return try { - val newColor = Color.parseColor(newText.toString()) + val newColor = newText.toColorInt() currentColor = newColor updateColor(false) } catch (_: IllegalArgumentException) { diff --git a/app/src/main/java/de/jrpie/android/launcher/preferences/LauncherPreferences$Config.java b/app/src/main/java/de/jrpie/android/launcher/preferences/LauncherPreferences$Config.java index c216911..d509ef2 100644 --- a/app/src/main/java/de/jrpie/android/launcher/preferences/LauncherPreferences$Config.java +++ b/app/src/main/java/de/jrpie/android/launcher/preferences/LauncherPreferences$Config.java @@ -5,8 +5,11 @@ import java.util.Set; import de.jrpie.android.launcher.R; import de.jrpie.android.launcher.actions.lock.LockMethod; -import de.jrpie.android.launcher.preferences.serialization.MapAppInfoStringPreferenceSerializer; -import de.jrpie.android.launcher.preferences.serialization.SetAppInfoPreferenceSerializer; +import de.jrpie.android.launcher.preferences.serialization.MapAbstractAppInfoStringPreferenceSerializer; +import de.jrpie.android.launcher.preferences.serialization.SetAbstractAppInfoPreferenceSerializer; +import de.jrpie.android.launcher.preferences.serialization.SetPinnedShortcutInfoPreferenceSerializer; +import de.jrpie.android.launcher.preferences.serialization.SetWidgetPanelSerializer; +import de.jrpie.android.launcher.preferences.serialization.SetWidgetSerializer; import de.jrpie.android.launcher.preferences.theme.Background; import de.jrpie.android.launcher.preferences.theme.ColorTheme; import de.jrpie.android.launcher.preferences.theme.Font; @@ -20,20 +23,24 @@ import eu.jonahbauer.android.preference.annotations.Preferences; r = R.class, value = { @PreferenceGroup(name = "internal", prefix = "settings_internal_", suffix = "_key", value = { + // set after the user finished the tutorial @Preference(name = "started", type = boolean.class, defaultValue = "false"), @Preference(name = "started_time", type = long.class), + // see PREFERENCE_VERSION in de.jrpie.android.launcher.preferences.Preferences.kt @Preference(name = "version_code", type = int.class, defaultValue = "-1"), }), @PreferenceGroup(name = "apps", prefix = "settings_apps_", suffix = "_key", value = { - @Preference(name = "favorites", type = Set.class, serializer = SetAppInfoPreferenceSerializer.class), - @Preference(name = "hidden", type = Set.class, serializer = SetAppInfoPreferenceSerializer.class), - @Preference(name = "custom_names", type = HashMap.class, serializer = MapAppInfoStringPreferenceSerializer.class), + @Preference(name = "favorites", type = Set.class, serializer = SetAbstractAppInfoPreferenceSerializer.class), + @Preference(name = "hidden", type = Set.class, serializer = SetAbstractAppInfoPreferenceSerializer.class), + @Preference(name = "pinned_shortcuts", type = Set.class, serializer = SetPinnedShortcutInfoPreferenceSerializer.class), + @Preference(name = "custom_names", type = HashMap.class, serializer = MapAbstractAppInfoStringPreferenceSerializer.class), @Preference(name = "hide_bound_apps", type = boolean.class, defaultValue = "false"), @Preference(name = "hide_paused_apps", type = boolean.class, defaultValue = "false"), @Preference(name = "hide_private_space_apps", type = boolean.class, defaultValue = "false"), }), @PreferenceGroup(name = "list", prefix = "settings_list_", suffix = "_key", value = { - @Preference(name = "layout", type = ListLayout.class, defaultValue = "DEFAULT") + @Preference(name = "layout", type = ListLayout.class, defaultValue = "DEFAULT"), + @Preference(name = "reverse_layout", type = boolean.class, defaultValue = "false") }), @PreferenceGroup(name = "gestures", prefix = "settings_gesture_", suffix = "_key", value = { }), @@ -59,13 +66,15 @@ import eu.jonahbauer.android.preference.annotations.Preferences; }), @PreferenceGroup(name = "display", prefix = "settings_display_", suffix = "_key", value = { @Preference(name = "screen_timeout_disabled", type = boolean.class, defaultValue = "false"), - @Preference(name = "full_screen", type = boolean.class, defaultValue = "true"), + @Preference(name = "hide_status_bar", type = boolean.class, defaultValue = "true"), + @Preference(name = "hide_navigation_bar", type = boolean.class, defaultValue = "false"), @Preference(name = "rotate_screen", type = boolean.class, defaultValue = "true"), }), @PreferenceGroup(name = "functionality", prefix = "settings_functionality_", suffix = "_key", value = { @Preference(name = "search_auto_launch", type = boolean.class, defaultValue = "true"), @Preference(name = "search_web", type = boolean.class, description = "false"), @Preference(name = "search_auto_open_keyboard", type = boolean.class, defaultValue = "true"), + @Preference(name = "search_auto_close_keyboard", type = boolean.class, defaultValue = "false"), }), @PreferenceGroup(name = "enabled_gestures", prefix = "settings_enabled_gestures_", suffix = "_key", value = { @Preference(name = "double_swipe", type = boolean.class, defaultValue = "true"), @@ -75,5 +84,9 @@ import eu.jonahbauer.android.preference.annotations.Preferences; @PreferenceGroup(name = "actions", prefix = "settings_actions_", suffix = "_key", value = { @Preference(name = "lock_method", type = LockMethod.class, defaultValue = "DEVICE_ADMIN"), }), + @PreferenceGroup(name = "widgets", prefix = "settings_widgets_", suffix= "_key", value = { + @Preference(name = "widgets", type = Set.class, serializer = SetWidgetSerializer.class), + @Preference(name = "custom_panels", type = Set.class, serializer = SetWidgetPanelSerializer.class) + }), }) public final class LauncherPreferences$Config {} \ No newline at end of file diff --git a/app/src/main/java/de/jrpie/android/launcher/preferences/ListLayout.kt b/app/src/main/java/de/jrpie/android/launcher/preferences/ListLayout.kt index e20945a..5f7b9d6 100644 --- a/app/src/main/java/de/jrpie/android/launcher/preferences/ListLayout.kt +++ b/app/src/main/java/de/jrpie/android/launcher/preferences/ListLayout.kt @@ -1,6 +1,7 @@ package de.jrpie.android.launcher.preferences import android.content.Context +import android.util.TypedValue import androidx.recyclerview.widget.GridLayoutManager import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView @@ -27,8 +28,10 @@ enum class ListLayout( GRID( { c -> val displayMetrics = c.resources.displayMetrics - val widthSp = displayMetrics.widthPixels / displayMetrics.scaledDensity - GridLayoutManager(c, (widthSp / 90).toInt()) + val widthColumnPx = + TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_SP, 90f, displayMetrics) + val numColumns = (displayMetrics.widthPixels / widthColumnPx).toInt() + GridLayoutManager(c, numColumns) }, R.layout.list_apps_row_variant_grid, false diff --git a/app/src/main/java/de/jrpie/android/launcher/preferences/Preferences.kt b/app/src/main/java/de/jrpie/android/launcher/preferences/Preferences.kt index 9460125..e8e717e 100644 --- a/app/src/main/java/de/jrpie/android/launcher/preferences/Preferences.kt +++ b/app/src/main/java/de/jrpie/android/launcher/preferences/Preferences.kt @@ -2,20 +2,29 @@ package de.jrpie.android.launcher.preferences import android.content.Context import android.util.Log +import de.jrpie.android.launcher.Application import de.jrpie.android.launcher.BuildConfig import de.jrpie.android.launcher.actions.Action +import de.jrpie.android.launcher.apps.AbstractAppInfo +import de.jrpie.android.launcher.apps.AbstractAppInfo.Companion.INVALID_USER import de.jrpie.android.launcher.apps.AppInfo import de.jrpie.android.launcher.apps.DetailedAppInfo import de.jrpie.android.launcher.preferences.legacy.migratePreferencesFromVersion1 import de.jrpie.android.launcher.preferences.legacy.migratePreferencesFromVersion2 +import de.jrpie.android.launcher.preferences.legacy.migratePreferencesFromVersion3 +import de.jrpie.android.launcher.preferences.legacy.migratePreferencesFromVersion4 import de.jrpie.android.launcher.preferences.legacy.migratePreferencesFromVersionUnknown import de.jrpie.android.launcher.ui.HomeActivity +import de.jrpie.android.launcher.widgets.ClockWidget +import de.jrpie.android.launcher.widgets.WidgetPanel +import de.jrpie.android.launcher.widgets.WidgetPosition +import de.jrpie.android.launcher.widgets.deleteAllWidgets /* Current version of the structure of preferences. * Increase when breaking changes are introduced and write an appropriate case in * `migratePreferencesToNewVersion` */ -const val PREFERENCE_VERSION = 3 +const val PREFERENCE_VERSION = 5 const val UNKNOWN_PREFERENCE_VERSION = -1 private const val TAG = "Launcher - Preferences" @@ -37,13 +46,22 @@ fun migratePreferencesToNewVersion(context: Context) { } 1 -> { - migratePreferencesFromVersion1() + migratePreferencesFromVersion1(context) Log.i(TAG, "migration of preferences complete (1 -> ${PREFERENCE_VERSION}).") } 2 -> { - migratePreferencesFromVersion2() + migratePreferencesFromVersion2(context) Log.i(TAG, "migration of preferences complete (2 -> ${PREFERENCE_VERSION}).") } + 3 -> { + migratePreferencesFromVersion3(context) + Log.i(TAG, "migration of preferences complete (3 -> ${PREFERENCE_VERSION}).") + } + + 4 -> { + migratePreferencesFromVersion4(context) + Log.i(TAG, "migration of preferences complete (4 -> ${PREFERENCE_VERSION}).") + } else -> { Log.w( @@ -64,18 +82,29 @@ fun resetPreferences(context: Context) { Log.i(TAG, "Resetting preferences") LauncherPreferences.clear() LauncherPreferences.internal().versionCode(PREFERENCE_VERSION) + deleteAllWidgets(context) + + LauncherPreferences.widgets().widgets( + setOf( + ClockWidget( + (context.applicationContext as Application).appWidgetHost.allocateAppWidgetId(), + WidgetPosition(1, 3, 10, 4), + WidgetPanel.HOME.id + ) + ) + ) - val hidden: MutableSet = mutableSetOf() + val hidden: MutableSet = mutableSetOf() val launcher = DetailedAppInfo.fromAppInfo( AppInfo( BuildConfig.APPLICATION_ID, HomeActivity::class.java.name, - AppInfo.INVALID_USER + INVALID_USER ), context ) - launcher?.app?.let { hidden.add(it) } - Log.i(TAG,"Hiding ${launcher?.app}") + launcher?.getRawInfo()?.let { hidden.add(it) } + Log.i(TAG,"Hiding ${launcher?.getRawInfo()}") LauncherPreferences.apps().hidden(hidden) Action.resetToDefaultActions(context) diff --git a/app/src/main/java/de/jrpie/android/launcher/preferences/legacy/Version1.kt b/app/src/main/java/de/jrpie/android/launcher/preferences/legacy/Version1.kt index a61980a..6cd9819 100644 --- a/app/src/main/java/de/jrpie/android/launcher/preferences/legacy/Version1.kt +++ b/app/src/main/java/de/jrpie/android/launcher/preferences/legacy/Version1.kt @@ -1,19 +1,33 @@ package de.jrpie.android.launcher.preferences.legacy +import android.content.Context +import androidx.core.content.edit import de.jrpie.android.launcher.actions.Action import de.jrpie.android.launcher.actions.AppAction import de.jrpie.android.launcher.actions.Gesture import de.jrpie.android.launcher.actions.LauncherAction +import de.jrpie.android.launcher.apps.AbstractAppInfo.Companion.INVALID_USER import de.jrpie.android.launcher.apps.AppInfo -import de.jrpie.android.launcher.apps.AppInfo.Companion.INVALID_USER import de.jrpie.android.launcher.preferences.LauncherPreferences import de.jrpie.android.launcher.preferences.PREFERENCE_VERSION -import de.jrpie.android.launcher.preferences.serialization.MapAppInfoStringPreferenceSerializer +import kotlinx.serialization.Serializable import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json import org.json.JSONException import org.json.JSONObject + +@Serializable +@Suppress("unused") +private class LegacyMapEntry(val key: AppInfo, val value: String) + +private fun serializeMapAppInfo(value: Map?): Set? { + return value?.map { (key, value) -> + Json.encodeToString(LegacyMapEntry(key, value)) + }?.toSet() +} + + val oldLauncherActionIds: Map = mapOf( Pair("launcher:settings", LauncherAction.SETTINGS), @@ -77,7 +91,7 @@ private fun Action.Companion.legacyFromPreference(id: String): Action? { private fun migrateAppInfoStringMap(key: String) { val preferences = LauncherPreferences.getSharedPreferences() - MapAppInfoStringPreferenceSerializer().serialize( + serializeMapAppInfo( preferences.getStringSet(key, setOf())?.mapNotNull { entry -> try { val obj = JSONObject(entry) @@ -89,7 +103,7 @@ private fun migrateAppInfoStringMap(key: String) { } }?.toMap(HashMap()) )?.let { - preferences.edit().putStringSet(key, it as Set).apply() + preferences.edit { putStringSet(key, it) } } } @@ -98,16 +112,16 @@ private fun migrateAppInfoSet(key: String) { .map(AppInfo.Companion::legacyDeserialize) .map(AppInfo::serialize) .toSet() - .let { LauncherPreferences.getSharedPreferences().edit().putStringSet(key, it).apply() } + .let { LauncherPreferences.getSharedPreferences().edit { putStringSet(key, it) } } } private fun migrateAction(key: String) { Action.legacyFromPreference(key)?.let { action -> - LauncherPreferences.getSharedPreferences().edit() - .putString(key, Json.encodeToString(action)) - .remove("$key.app") - .remove("$key.user") - .apply() + LauncherPreferences.getSharedPreferences().edit { + putString(key, Json.encodeToString(action)) + .remove("$key.app") + .remove("$key.user") + } } } @@ -116,7 +130,7 @@ private fun migrateAction(key: String) { * Migrate preferences from version 1 (used until version j-0.0.18) to the current format * (see [PREFERENCE_VERSION]) */ -fun migratePreferencesFromVersion1() { +fun migratePreferencesFromVersion1(context: Context) { assert(LauncherPreferences.internal().versionCode() == 1) Gesture.entries.forEach { g -> migrateAction(g.id) } migrateAppInfoSet(LauncherPreferences.apps().keys().hidden()) @@ -124,5 +138,5 @@ fun migratePreferencesFromVersion1() { migrateAppInfoStringMap(LauncherPreferences.apps().keys().customNames()) LauncherPreferences.internal().versionCode(2) - migratePreferencesFromVersion2() + migratePreferencesFromVersion2(context) } \ No newline at end of file diff --git a/app/src/main/java/de/jrpie/android/launcher/preferences/legacy/Version2.kt b/app/src/main/java/de/jrpie/android/launcher/preferences/legacy/Version2.kt index bcac3ae..9714359 100644 --- a/app/src/main/java/de/jrpie/android/launcher/preferences/legacy/Version2.kt +++ b/app/src/main/java/de/jrpie/android/launcher/preferences/legacy/Version2.kt @@ -1,5 +1,6 @@ package de.jrpie.android.launcher.preferences.legacy +import android.content.Context import de.jrpie.android.launcher.actions.Action import de.jrpie.android.launcher.actions.Gesture import de.jrpie.android.launcher.actions.LauncherAction @@ -11,10 +12,10 @@ import de.jrpie.android.launcher.preferences.PREFERENCE_VERSION * Migrate preferences from version 2 (used until version 0.0.21) to the current format * (see [PREFERENCE_VERSION]) */ -fun migratePreferencesFromVersion2() { - assert(PREFERENCE_VERSION == 3) +fun migratePreferencesFromVersion2(context: Context) { assert(LauncherPreferences.internal().versionCode() == 2) // previously there was no setting for this Action.setActionForGesture(Gesture.BACK, LauncherAction.CHOOSE) LauncherPreferences.internal().versionCode(3) + migratePreferencesFromVersion3(context) } \ No newline at end of file diff --git a/app/src/main/java/de/jrpie/android/launcher/preferences/legacy/Version3.kt b/app/src/main/java/de/jrpie/android/launcher/preferences/legacy/Version3.kt new file mode 100644 index 0000000..e0a8447 --- /dev/null +++ b/app/src/main/java/de/jrpie/android/launcher/preferences/legacy/Version3.kt @@ -0,0 +1,85 @@ +package de.jrpie.android.launcher.preferences.legacy + +import android.content.Context +import android.content.SharedPreferences +import android.content.SharedPreferences.Editor +import androidx.core.content.edit +import de.jrpie.android.launcher.apps.AbstractAppInfo +import de.jrpie.android.launcher.apps.AppInfo +import de.jrpie.android.launcher.preferences.LauncherPreferences +import de.jrpie.android.launcher.preferences.PREFERENCE_VERSION +import de.jrpie.android.launcher.preferences.serialization.MapAbstractAppInfoStringPreferenceSerializer +import de.jrpie.android.launcher.preferences.serialization.SetAbstractAppInfoPreferenceSerializer +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json + +/** + * Migrate preferences from version 3 (used until version 0.0.23) to the current format + * (see [PREFERENCE_VERSION]) + */ + + +fun deserializeSet(value: Set?): Set? { + return value?.map { + Json.decodeFromString(it) + }?.toHashSet() +} + +fun deserializeMap(value: Set?): HashMap? { + return value?.associateTo(HashMap()) { + val entry = Json.decodeFromString(it) + Pair(entry.key, entry.value) + } +} + +@Serializable +private class MapEntry(val key: AppInfo, val value: String) + +private fun migrateSetAppInfo(key: String, preferences: SharedPreferences, editor: Editor) { + try { + val serializer = SetAbstractAppInfoPreferenceSerializer() + val set = HashSet() + + deserializeSet(preferences.getStringSet(key, null))?.let { + set.addAll(it) + } + @Suppress("UNCHECKED_CAST") + editor.putStringSet( + key, + serializer.serialize(set as java.util.Set) as Set? + ) + } catch (e: Exception) { + e.printStackTrace() + editor.putStringSet(key, null) + } + +} +private fun migrateMapAppInfoString(key: String, preferences: SharedPreferences, editor: Editor ) { + try { + val serializer = MapAbstractAppInfoStringPreferenceSerializer() + val map = HashMap() + + deserializeMap(preferences.getStringSet(key, null))?.let { + map.putAll(it) + } + @Suppress("UNCHECKED_CAST") + editor.putStringSet(key, serializer.serialize(map) as Set?) + } catch (e: Exception) { + e.printStackTrace() + editor.putStringSet(key, null) + } +} + +fun migratePreferencesFromVersion3(context: Context) { + assert(LauncherPreferences.internal().versionCode() == 3) + + val preferences = LauncherPreferences.getSharedPreferences() + preferences.edit { + migrateSetAppInfo(LauncherPreferences.apps().keys().favorites(), preferences, this) + migrateSetAppInfo(LauncherPreferences.apps().keys().hidden(), preferences, this) + migrateMapAppInfoString(LauncherPreferences.apps().keys().customNames(), preferences, this) + } + + LauncherPreferences.internal().versionCode(4) + migratePreferencesFromVersion4(context) +} \ No newline at end of file diff --git a/app/src/main/java/de/jrpie/android/launcher/preferences/legacy/Version4.kt b/app/src/main/java/de/jrpie/android/launcher/preferences/legacy/Version4.kt new file mode 100644 index 0000000..d4c7441 --- /dev/null +++ b/app/src/main/java/de/jrpie/android/launcher/preferences/legacy/Version4.kt @@ -0,0 +1,27 @@ +package de.jrpie.android.launcher.preferences.legacy + +import android.content.Context +import de.jrpie.android.launcher.Application +import de.jrpie.android.launcher.preferences.LauncherPreferences +import de.jrpie.android.launcher.preferences.PREFERENCE_VERSION +import de.jrpie.android.launcher.widgets.ClockWidget +import de.jrpie.android.launcher.widgets.WidgetPanel +import de.jrpie.android.launcher.widgets.WidgetPosition + +fun migratePreferencesFromVersion4(context: Context) { + assert(PREFERENCE_VERSION == 5) + assert(LauncherPreferences.internal().versionCode() == 4) + + LauncherPreferences.widgets().widgets( + setOf( + ClockWidget( + (context.applicationContext as Application).appWidgetHost.allocateAppWidgetId(), + WidgetPosition(1, 3, 10, 4), + WidgetPanel.HOME.id + ) + ) + ) + + + LauncherPreferences.internal().versionCode(5) +} \ No newline at end of file diff --git a/app/src/main/java/de/jrpie/android/launcher/preferences/legacy/VersionUnknown.kt b/app/src/main/java/de/jrpie/android/launcher/preferences/legacy/VersionUnknown.kt index a33670b..f954b31 100644 --- a/app/src/main/java/de/jrpie/android/launcher/preferences/legacy/VersionUnknown.kt +++ b/app/src/main/java/de/jrpie/android/launcher/preferences/legacy/VersionUnknown.kt @@ -3,12 +3,12 @@ package de.jrpie.android.launcher.preferences.legacy import android.content.Context import android.content.SharedPreferences import android.util.Log +import androidx.core.content.edit import de.jrpie.android.launcher.preferences.LauncherPreferences import de.jrpie.android.launcher.preferences.theme.Background import de.jrpie.android.launcher.preferences.theme.ColorTheme - private fun migrateStringPreference( oldPrefs: SharedPreferences, newPreferences: SharedPreferences.Editor, @@ -64,318 +64,317 @@ fun migratePreferencesFromVersionUnknown(context: Context) { return } - val newPrefs = LauncherPreferences.getSharedPreferences().edit() + LauncherPreferences.getSharedPreferences().edit { - migrateBooleanPreference( - oldPrefs, - newPrefs, - "startedBefore", - "internal.started_before", - false - ) + migrateBooleanPreference( + oldPrefs, + this, + "startedBefore", + "internal.started_before", + false + ) - migrateStringPreference( - oldPrefs, - newPrefs, - "action_volumeUpApp", - "action.volume_up.app", - "" - ) - migrateIntPreference( - oldPrefs, - newPrefs, - "action_volumeUpApp_user", - "action.volume_up.user", - -1 - ) - migrateStringPreference( - oldPrefs, - newPrefs, - "action_volumeDownApp", - "action.volume_down.app", - "" - ) - migrateIntPreference( - oldPrefs, - newPrefs, - "action_volumeDownApp_user", - "action.volume_down.user", - -1 - ) - migrateStringPreference(oldPrefs, newPrefs, "action_timeApp", "action.time.app", "") - migrateIntPreference(oldPrefs, newPrefs, "action_timeApp_user", "action.time.user", -1) - migrateStringPreference(oldPrefs, newPrefs, "action_dateApp", "action.date.app", "") - migrateIntPreference(oldPrefs, newPrefs, "action_dateApp_user", "action.date.user", -1) - migrateStringPreference( - oldPrefs, - newPrefs, - "action_longClickApp", - "action.long_click.app", - "" - ) - migrateIntPreference( - oldPrefs, - newPrefs, - "action_longClickApp_user", - "action.long_click.user", - -1 - ) - migrateStringPreference( - oldPrefs, - newPrefs, - "action_doubleClickApp", - "action.double_click.app", - "" - ) - migrateIntPreference( - oldPrefs, - newPrefs, - "action_doubleClickApp_user", - "action.double_click.user", - -1 - ) - migrateStringPreference(oldPrefs, newPrefs, "action_upApp", "action.up.app", "") - migrateIntPreference(oldPrefs, newPrefs, "action_upApp_user", "action.up.user", -1) - migrateStringPreference( - oldPrefs, - newPrefs, - "action_up_leftApp", - "action.up_left.app", - "" - ) - migrateIntPreference( - oldPrefs, - newPrefs, - "action_up_leftApp_user", - "action.up_left.user", - -1 - ) - migrateStringPreference( - oldPrefs, - newPrefs, - "action_up_rightApp", - "action.up_right.app", - "" - ) - migrateIntPreference( - oldPrefs, - newPrefs, - "action_up_rightApp_user", - "action.up_right.user", - -1 - ) - migrateStringPreference( - oldPrefs, - newPrefs, - "action_doubleUpApp", - "action.double_up.app", - "" - ) - migrateIntPreference( - oldPrefs, - newPrefs, - "action_doubleUpApp_user", - "action.double_up.user", - -1 - ) - migrateStringPreference(oldPrefs, newPrefs, "action_downApp", "action.down.app", "") - migrateIntPreference(oldPrefs, newPrefs, "action_downApp_user", "action.down.user", -1) - migrateStringPreference( - oldPrefs, - newPrefs, - "action_down_leftApp", - "action.down_left.app", - "" - ) - migrateIntPreference( - oldPrefs, - newPrefs, - "action_down_leftApp_user", - "action.down_left.user", - -1 - ) - migrateStringPreference( - oldPrefs, - newPrefs, - "action_down_rightApp", - "action.down_right.app", - "" - ) - migrateIntPreference( - oldPrefs, - newPrefs, - "action_down_rightApp_user", - "action.down_right.user", - -1 - ) - migrateStringPreference( - oldPrefs, - newPrefs, - "action_doubleDownApp", - "action.double_down.app", - "" - ) - migrateIntPreference( - oldPrefs, - newPrefs, - "action_doubleDownApp_user", - "action.double_down.user", - -1 - ) - migrateStringPreference(oldPrefs, newPrefs, "action_leftApp", "action.left.app", "") - migrateIntPreference(oldPrefs, newPrefs, "action_leftApp_user", "action.left.user", -1) - migrateStringPreference( - oldPrefs, - newPrefs, - "action_left_topApp", - "action.left_top.app", - "" - ) - migrateIntPreference( - oldPrefs, - newPrefs, - "action_left_topApp_user", - "action.left_top.user", - -1 - ) - migrateStringPreference( - oldPrefs, - newPrefs, - "action_left_bottomApp", - "action.left_bottom.app", - "" - ) - migrateIntPreference( - oldPrefs, - newPrefs, - "action_left_bottomApp_user", - "action.left_bottom.user", - -1 - ) - migrateStringPreference( - oldPrefs, - newPrefs, - "action_doubleLeftApp", - "action.double_left.app", - "" - ) - migrateIntPreference( - oldPrefs, - newPrefs, - "action_doubleLeftApp_user", - "action.double_left.user", - -1 - ) - migrateStringPreference(oldPrefs, newPrefs, "action_rightApp", "action.right.app", "") - migrateIntPreference( - oldPrefs, - newPrefs, - "action_rightApp_user", - "action.right.user", - -1 - ) - migrateStringPreference( - oldPrefs, - newPrefs, - "action_right_topApp", - "action.right_top.app", - "" - ) - migrateIntPreference( - oldPrefs, - newPrefs, - "action_right_topApp_user", - "action.right_top.user", - -1 - ) - migrateStringPreference( - oldPrefs, - newPrefs, - "action_right_bottomApp", - "action.right_bottom.app", - "" - ) - migrateIntPreference( - oldPrefs, - newPrefs, - "action_right_bottomApp_user", - "action.right_bottom.user", - -1 - ) - migrateStringPreference( - oldPrefs, - newPrefs, - "action_doubleRightApp", - "action.double_right.app", - "" - ) - migrateIntPreference( - oldPrefs, - newPrefs, - "action_doubleRightApp_user", - "action.double_right.user", - -1 - ) - migrateBooleanPreference(oldPrefs, newPrefs, "timeVisible", "clock.time_visible", true) - migrateBooleanPreference(oldPrefs, newPrefs, "dateVisible", "clock.date_visible", true) - migrateBooleanPreference( - oldPrefs, - newPrefs, - "dateLocalized", - "clock.date_localized", - false - ) - migrateBooleanPreference( - oldPrefs, - newPrefs, - "dateTimeFlip", - "clock.date_time_flip", - false - ) - migrateBooleanPreference( - oldPrefs, - newPrefs, - "disableTimeout", - "display.disable_timeout", - false - ) - migrateBooleanPreference( - oldPrefs, - newPrefs, - "useFullScreen", - "display.use_full_screen", - true - ) - migrateBooleanPreference( - oldPrefs, - newPrefs, - "enableDoubleActions", - "enabled_gestures.double_actions", - true - ) - migrateBooleanPreference( - oldPrefs, - newPrefs, - "enableEdgeActions", - "enabled_gestures.edge_actions", - true - ) - migrateBooleanPreference( - oldPrefs, - newPrefs, - "searchAutoLaunch", - "functionality.search_auto_launch", - true - ) - migrateBooleanPreference( - oldPrefs, - newPrefs, - "searchAutoKeyboard", - "functionality.search_auto_keyboard", - true - ) - - newPrefs.apply() + migrateStringPreference( + oldPrefs, + this, + "action_volumeUpApp", + "action.volume_up.app", + "" + ) + migrateIntPreference( + oldPrefs, + this, + "action_volumeUpApp_user", + "action.volume_up.user", + -1 + ) + migrateStringPreference( + oldPrefs, + this, + "action_volumeDownApp", + "action.volume_down.app", + "" + ) + migrateIntPreference( + oldPrefs, + this, + "action_volumeDownApp_user", + "action.volume_down.user", + -1 + ) + migrateStringPreference(oldPrefs, this, "action_timeApp", "action.time.app", "") + migrateIntPreference(oldPrefs, this, "action_timeApp_user", "action.time.user", -1) + migrateStringPreference(oldPrefs, this, "action_dateApp", "action.date.app", "") + migrateIntPreference(oldPrefs, this, "action_dateApp_user", "action.date.user", -1) + migrateStringPreference( + oldPrefs, + this, + "action_longClickApp", + "action.long_click.app", + "" + ) + migrateIntPreference( + oldPrefs, + this, + "action_longClickApp_user", + "action.long_click.user", + -1 + ) + migrateStringPreference( + oldPrefs, + this, + "action_doubleClickApp", + "action.double_click.app", + "" + ) + migrateIntPreference( + oldPrefs, + this, + "action_doubleClickApp_user", + "action.double_click.user", + -1 + ) + migrateStringPreference(oldPrefs, this, "action_upApp", "action.up.app", "") + migrateIntPreference(oldPrefs, this, "action_upApp_user", "action.up.user", -1) + migrateStringPreference( + oldPrefs, + this, + "action_up_leftApp", + "action.up_left.app", + "" + ) + migrateIntPreference( + oldPrefs, + this, + "action_up_leftApp_user", + "action.up_left.user", + -1 + ) + migrateStringPreference( + oldPrefs, + this, + "action_up_rightApp", + "action.up_right.app", + "" + ) + migrateIntPreference( + oldPrefs, + this, + "action_up_rightApp_user", + "action.up_right.user", + -1 + ) + migrateStringPreference( + oldPrefs, + this, + "action_doubleUpApp", + "action.double_up.app", + "" + ) + migrateIntPreference( + oldPrefs, + this, + "action_doubleUpApp_user", + "action.double_up.user", + -1 + ) + migrateStringPreference(oldPrefs, this, "action_downApp", "action.down.app", "") + migrateIntPreference(oldPrefs, this, "action_downApp_user", "action.down.user", -1) + migrateStringPreference( + oldPrefs, + this, + "action_down_leftApp", + "action.down_left.app", + "" + ) + migrateIntPreference( + oldPrefs, + this, + "action_down_leftApp_user", + "action.down_left.user", + -1 + ) + migrateStringPreference( + oldPrefs, + this, + "action_down_rightApp", + "action.down_right.app", + "" + ) + migrateIntPreference( + oldPrefs, + this, + "action_down_rightApp_user", + "action.down_right.user", + -1 + ) + migrateStringPreference( + oldPrefs, + this, + "action_doubleDownApp", + "action.double_down.app", + "" + ) + migrateIntPreference( + oldPrefs, + this, + "action_doubleDownApp_user", + "action.double_down.user", + -1 + ) + migrateStringPreference(oldPrefs, this, "action_leftApp", "action.left.app", "") + migrateIntPreference(oldPrefs, this, "action_leftApp_user", "action.left.user", -1) + migrateStringPreference( + oldPrefs, + this, + "action_left_topApp", + "action.left_top.app", + "" + ) + migrateIntPreference( + oldPrefs, + this, + "action_left_topApp_user", + "action.left_top.user", + -1 + ) + migrateStringPreference( + oldPrefs, + this, + "action_left_bottomApp", + "action.left_bottom.app", + "" + ) + migrateIntPreference( + oldPrefs, + this, + "action_left_bottomApp_user", + "action.left_bottom.user", + -1 + ) + migrateStringPreference( + oldPrefs, + this, + "action_doubleLeftApp", + "action.double_left.app", + "" + ) + migrateIntPreference( + oldPrefs, + this, + "action_doubleLeftApp_user", + "action.double_left.user", + -1 + ) + migrateStringPreference(oldPrefs, this, "action_rightApp", "action.right.app", "") + migrateIntPreference( + oldPrefs, + this, + "action_rightApp_user", + "action.right.user", + -1 + ) + migrateStringPreference( + oldPrefs, + this, + "action_right_topApp", + "action.right_top.app", + "" + ) + migrateIntPreference( + oldPrefs, + this, + "action_right_topApp_user", + "action.right_top.user", + -1 + ) + migrateStringPreference( + oldPrefs, + this, + "action_right_bottomApp", + "action.right_bottom.app", + "" + ) + migrateIntPreference( + oldPrefs, + this, + "action_right_bottomApp_user", + "action.right_bottom.user", + -1 + ) + migrateStringPreference( + oldPrefs, + this, + "action_doubleRightApp", + "action.double_right.app", + "" + ) + migrateIntPreference( + oldPrefs, + this, + "action_doubleRightApp_user", + "action.double_right.user", + -1 + ) + migrateBooleanPreference(oldPrefs, this, "timeVisible", "clock.time_visible", true) + migrateBooleanPreference(oldPrefs, this, "dateVisible", "clock.date_visible", true) + migrateBooleanPreference( + oldPrefs, + this, + "dateLocalized", + "clock.date_localized", + false + ) + migrateBooleanPreference( + oldPrefs, + this, + "dateTimeFlip", + "clock.date_time_flip", + false + ) + migrateBooleanPreference( + oldPrefs, + this, + "disableTimeout", + "display.disable_timeout", + false + ) + migrateBooleanPreference( + oldPrefs, + this, + "useFullScreen", + "display.use_full_screen", + true + ) + migrateBooleanPreference( + oldPrefs, + this, + "enableDoubleActions", + "enabled_gestures.double_actions", + true + ) + migrateBooleanPreference( + oldPrefs, + this, + "enableEdgeActions", + "enabled_gestures.edge_actions", + true + ) + migrateBooleanPreference( + oldPrefs, + this, + "searchAutoLaunch", + "functionality.search_auto_launch", + true + ) + migrateBooleanPreference( + oldPrefs, + this, + "searchAutoKeyboard", + "functionality.search_auto_keyboard", + true + ) + } when (oldPrefs.getString("theme", "finn")) { "finn" -> { @@ -393,5 +392,5 @@ fun migratePreferencesFromVersionUnknown(context: Context) { LauncherPreferences.internal().versionCode(1) Log.i(TAG, "migrated preferences to version 1.") - migratePreferencesFromVersion1() + migratePreferencesFromVersion1(context) } \ No newline at end of file diff --git a/app/src/main/java/de/jrpie/android/launcher/preferences/serialization/PreferenceSerializers.kt b/app/src/main/java/de/jrpie/android/launcher/preferences/serialization/PreferenceSerializers.kt index 041fe4d..7b5d794 100644 --- a/app/src/main/java/de/jrpie/android/launcher/preferences/serialization/PreferenceSerializers.kt +++ b/app/src/main/java/de/jrpie/android/launcher/preferences/serialization/PreferenceSerializers.kt @@ -2,7 +2,10 @@ package de.jrpie.android.launcher.preferences.serialization -import de.jrpie.android.launcher.apps.AppInfo +import de.jrpie.android.launcher.apps.AbstractAppInfo +import de.jrpie.android.launcher.apps.PinnedShortcutInfo +import de.jrpie.android.launcher.widgets.Widget +import de.jrpie.android.launcher.widgets.WidgetPanel import eu.jonahbauer.android.preference.annotations.serializer.PreferenceSerializationException import eu.jonahbauer.android.preference.annotations.serializer.PreferenceSerializer import kotlinx.serialization.Serializable @@ -10,40 +13,95 @@ import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json -// Serializers for [LauncherPreference$Config] + @Suppress("UNCHECKED_CAST") -class SetAppInfoPreferenceSerializer : - PreferenceSerializer?, java.util.Set?> { +class SetAbstractAppInfoPreferenceSerializer : + PreferenceSerializer?, java.util.Set?> { @Throws(PreferenceSerializationException::class) - override fun serialize(value: java.util.Set?): java.util.Set { - return value?.map(AppInfo::serialize)?.toHashSet() as java.util.Set + override fun serialize(value: java.util.Set?): java.util.Set { + return value?.map(AbstractAppInfo::serialize) + ?.toHashSet() as java.util.Set } @Throws(PreferenceSerializationException::class) - override fun deserialize(value: java.util.Set?): java.util.Set? { - return value?.map (java.lang.String::toString)?.map(AppInfo::deserialize)?.toHashSet() as? java.util.Set + override fun deserialize(value: java.util.Set?): java.util.Set? { + return value?.map(java.lang.String::toString)?.map(AbstractAppInfo::deserialize) + ?.toHashSet() as? java.util.Set + } +} + + +@Suppress("UNCHECKED_CAST") +class SetWidgetSerializer : + PreferenceSerializer?, java.util.Set?> { + @Throws(PreferenceSerializationException::class) + override fun serialize(value: java.util.Set?): java.util.Set? { + return value?.map(Widget::serialize) + ?.toHashSet() as? java.util.Set + } + + @Throws(PreferenceSerializationException::class) + override fun deserialize(value: java.util.Set?): java.util.Set? { + return value?.map(java.lang.String::toString)?.map(Widget::deserialize) + ?.toHashSet() as? java.util.Set } } @Suppress("UNCHECKED_CAST") -class MapAppInfoStringPreferenceSerializer : - PreferenceSerializer?, java.util.Set?> { - - @Serializable - private class MapEntry(val key: AppInfo, val value: String) +class SetWidgetPanelSerializer : + PreferenceSerializer?, java.util.Set?> { + @Throws(PreferenceSerializationException::class) + override fun serialize(value: java.util.Set?): java.util.Set? { + return value?.map(WidgetPanel::serialize) + ?.toHashSet() as? java.util.Set + } @Throws(PreferenceSerializationException::class) - override fun serialize(value: java.util.HashMap?): java.util.Set? { + override fun deserialize(value: java.util.Set?): java.util.Set? { + return value?.map(java.lang.String::toString)?.map(WidgetPanel::deserialize) + ?.toHashSet() as? java.util.Set + } +} + + +@Suppress("UNCHECKED_CAST") +class SetPinnedShortcutInfoPreferenceSerializer : + PreferenceSerializer?, java.util.Set?> { + @Throws(PreferenceSerializationException::class) + override fun serialize(value: java.util.Set?): java.util.Set { + return value?.map { Json.encodeToString(it) } + ?.toHashSet() as java.util.Set + } + + @Throws(PreferenceSerializationException::class) + override fun deserialize(value: java.util.Set?): java.util.Set? { + return value?.map(java.lang.String::toString) + ?.map { Json.decodeFromString(it) } + ?.toHashSet() as? java.util.Set + } +} + + +@Suppress("UNCHECKED_CAST") +class MapAbstractAppInfoStringPreferenceSerializer : + PreferenceSerializer?, java.util.Set?> { + + @Serializable + private class MapEntry(val key: AbstractAppInfo, val value: String) + + @Throws(PreferenceSerializationException::class) + override fun serialize(value: java.util.HashMap?): java.util.Set? { return value?.map { (key, value) -> Json.encodeToString(MapEntry(key, value)) }?.toHashSet() as? java.util.Set } @Throws(PreferenceSerializationException::class) - override fun deserialize(value: java.util.Set?): java.util.HashMap? { + override fun deserialize(value: java.util.Set?): java.util.HashMap? { return value?.associateTo(HashMap()) { val entry = Json.decodeFromString(it.toString()) Pair(entry.key, entry.value) } } } + diff --git a/app/src/main/java/de/jrpie/android/launcher/preferences/theme/ColorTheme.kt b/app/src/main/java/de/jrpie/android/launcher/preferences/theme/ColorTheme.kt index 816d94f..d3088f4 100644 --- a/app/src/main/java/de/jrpie/android/launcher/preferences/theme/ColorTheme.kt +++ b/app/src/main/java/de/jrpie/android/launcher/preferences/theme/ColorTheme.kt @@ -5,7 +5,6 @@ import android.content.res.Resources import com.google.android.material.color.DynamicColors import de.jrpie.android.launcher.R -@Suppress("unused") enum class ColorTheme( private val id: Int, private val labelResource: Int, diff --git a/app/src/main/java/de/jrpie/android/launcher/ui/Helper.kt b/app/src/main/java/de/jrpie/android/launcher/ui/Helper.kt index b8fc82e..a863c67 100644 --- a/app/src/main/java/de/jrpie/android/launcher/ui/Helper.kt +++ b/app/src/main/java/de/jrpie/android/launcher/ui/Helper.kt @@ -1,5 +1,6 @@ package de.jrpie.android.launcher.ui +import android.app.Activity import android.content.Context import android.graphics.ColorMatrix import android.graphics.ColorMatrixColorFilter @@ -27,17 +28,28 @@ fun View.blink( } // Taken from: https://stackoverflow.com/a/30340794/12787264 -fun ImageView.transformGrayscale() { - this.colorFilter = ColorMatrixColorFilter(ColorMatrix().apply { - setSaturation(0f) - }) +fun ImageView.transformGrayscale(grayscale: Boolean) { + this.colorFilter = if (grayscale) { + ColorMatrixColorFilter(ColorMatrix().apply { + setSaturation(0f) + }) + } else { + null + } } -// Taken from https://stackoverflow.com/a/50743764/12787264 +// Taken from https://stackoverflow.com/a/50743764 fun View.openSoftKeyboard(context: Context) { this.requestFocus() - // open the soft keyboard - val imm = context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager - imm.showSoftInput(this, InputMethodManager.SHOW_IMPLICIT) + (context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager) + .showSoftInput(this, InputMethodManager.SHOW_IMPLICIT) } + +// https://stackoverflow.com/a/17789187 +fun closeSoftKeyboard(activity: Activity) { + activity.currentFocus?.let { focus -> + (activity.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager) + .hideSoftInputFromWindow( focus.windowToken, 0 ) + } +} \ No newline at end of file diff --git a/app/src/main/java/de/jrpie/android/launcher/ui/HomeActivity.kt b/app/src/main/java/de/jrpie/android/launcher/ui/HomeActivity.kt index 973e0ca..192a8e9 100644 --- a/app/src/main/java/de/jrpie/android/launcher/ui/HomeActivity.kt +++ b/app/src/main/java/de/jrpie/android/launcher/ui/HomeActivity.kt @@ -1,25 +1,25 @@ package de.jrpie.android.launcher.ui import android.annotation.SuppressLint +import android.app.Activity import android.content.SharedPreferences +import android.content.res.Configuration import android.content.res.Resources import android.os.Build import android.os.Bundle -import android.util.DisplayMetrics import android.view.KeyEvent import android.view.MotionEvent import android.view.View import android.window.OnBackInvokedDispatcher -import androidx.appcompat.app.AppCompatActivity -import androidx.core.view.isVisible +import de.jrpie.android.launcher.Application import de.jrpie.android.launcher.R import de.jrpie.android.launcher.actions.Action import de.jrpie.android.launcher.actions.Gesture import de.jrpie.android.launcher.actions.LauncherAction import de.jrpie.android.launcher.databinding.HomeBinding +import de.jrpie.android.launcher.openTutorial import de.jrpie.android.launcher.preferences.LauncherPreferences import de.jrpie.android.launcher.ui.tutorial.TutorialActivity -import java.util.Locale /** * [HomeActivity] is the actual application Launcher, @@ -33,10 +33,10 @@ import java.util.Locale * - Setting global variables (preferences etc.) * - Opening the [TutorialActivity] on new installations */ -class HomeActivity : UIObject, AppCompatActivity() { +class HomeActivity : UIObject, Activity() { private lateinit var binding: HomeBinding - private lateinit var touchGestureDetector: TouchGestureDetector + private var touchGestureDetector: TouchGestureDetector? = null private var sharedPreferencesListener = SharedPreferences.OnSharedPreferenceChangeListener { _, prefKey -> @@ -44,35 +44,25 @@ class HomeActivity : UIObject, AppCompatActivity() { prefKey?.startsWith("display.") == true ) { recreate() + } else if (prefKey?.startsWith("action.") == true) { + updateSettingsFallbackButtonVisibility() + } else if (prefKey == LauncherPreferences.widgets().keys().widgets()) { + binding.homeWidgetContainer.updateWidgets(this@HomeActivity, + LauncherPreferences.widgets().widgets() + ) } - if (prefKey?.startsWith("action.") == true) { - updateSettingsFallbackButtonVisibility() - } } override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) + super.onCreate(savedInstanceState) super.onCreate() - val displayMetrics = DisplayMetrics() - windowManager.defaultDisplay.getMetrics(displayMetrics) - - val width = displayMetrics.widthPixels - val height = displayMetrics.heightPixels - - touchGestureDetector = TouchGestureDetector( - this, - width, - height, - LauncherPreferences.enabled_gestures().edgeSwipeEdgeWidth() / 100f - ) - // Initialise layout binding = HomeBinding.inflate(layoutInflater) - setContentView(binding.root) + setContentView(binding.root) // Handle back key / gesture on Android 13+, cf. onKeyDown() if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { @@ -85,20 +75,45 @@ class HomeActivity : UIObject, AppCompatActivity() { binding.buttonFallbackSettings.setOnClickListener { LauncherAction.SETTINGS.invoke(this) } + } - + override fun onConfigurationChanged(newConfig: Configuration) { + super.onConfigurationChanged(newConfig) + touchGestureDetector?.updateScreenSize(windowManager) } override fun onStart() { - super.onStart() - + super.onStart() super.onStart() + // If the tutorial was not finished, start it + if (!LauncherPreferences.internal().started()) { + openTutorial(this) + } + LauncherPreferences.getSharedPreferences() .registerOnSharedPreferenceChangeListener(sharedPreferencesListener) + (application as Application).appWidgetHost.startListening() + } + + + override fun onStop() { + (application as Application).appWidgetHost.stopListening() + super.onStop() + } + + override fun onWindowFocusChanged(hasFocus: Boolean) { + super.onWindowFocusChanged(hasFocus) + + if (hasFocus && LauncherPreferences.display().hideNavigationBar()) { + hideNavigationBar() + } + } + + private fun updateSettingsFallbackButtonVisibility() { // If µLauncher settings can not be reached from any action bound to an enabled gesture, // show the fallback button. @@ -113,44 +128,6 @@ class HomeActivity : UIObject, AppCompatActivity() { } } - private fun initClock() { - val locale = Locale.getDefault() - val dateVisible = LauncherPreferences.clock().dateVisible() - val timeVisible = LauncherPreferences.clock().timeVisible() - - var dateFMT = "yyyy-MM-dd" - var timeFMT = "HH:mm" - if (LauncherPreferences.clock().showSeconds()) { - timeFMT += ":ss" - } - - if (LauncherPreferences.clock().localized()) { - dateFMT = android.text.format.DateFormat.getBestDateTimePattern(locale, dateFMT) - timeFMT = android.text.format.DateFormat.getBestDateTimePattern(locale, timeFMT) - } - - var upperFormat = dateFMT - var lowerFormat = timeFMT - var upperVisible = dateVisible - var lowerVisible = timeVisible - - if (LauncherPreferences.clock().flipDateTime()) { - upperFormat = lowerFormat.also { lowerFormat = upperFormat } - upperVisible = lowerVisible.also { lowerVisible = upperVisible } - } - - binding.homeUpperView.isVisible = upperVisible - binding.homeLowerView.isVisible = lowerVisible - - binding.homeUpperView.setTextColor(LauncherPreferences.clock().color()) - binding.homeLowerView.setTextColor(LauncherPreferences.clock().color()) - - binding.homeLowerView.format24Hour = lowerFormat - binding.homeUpperView.format24Hour = upperFormat - binding.homeLowerView.format12Hour = lowerFormat - binding.homeUpperView.format12Hour = upperFormat - } - override fun getTheme(): Resources.Theme { val mTheme = modifyTheme(super.getTheme()) mTheme.applyStyle(R.style.backgroundWallpaper, true) @@ -166,11 +143,33 @@ class HomeActivity : UIObject, AppCompatActivity() { override fun onResume() { super.onResume() - touchGestureDetector.edgeWidth = + /* This should be initialized in onCreate() + However on some devices there seems to be a bug where the touchGestureDetector + is not working properly after resuming the app. + Reinitializing the touchGestureDetector every time the app is resumed might help to fix that. + (see issue #138) + */ + touchGestureDetector = TouchGestureDetector( + this, 0, 0, LauncherPreferences.enabled_gestures().edgeSwipeEdgeWidth() / 100f + ).also { + it.updateScreenSize(windowManager) + } - initClock() + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + binding.root.setOnApplyWindowInsetsListener { _, windowInsets -> + @Suppress("deprecation") // required to support API 29 + val insets = windowInsets.systemGestureInsets + touchGestureDetector?.setSystemGestureInsets(insets) + + windowInsets + } + } updateSettingsFallbackButtonVisibility() + + binding.homeWidgetContainer.updateWidgets(this@HomeActivity, + LauncherPreferences.widgets().widgets() + ) } override fun onDestroy() { @@ -186,6 +185,7 @@ class HomeActivity : UIObject, AppCompatActivity() { // Only used pre Android 13, cf. onBackInvokedDispatcher handleBack() } + KeyEvent.KEYCODE_VOLUME_UP -> { if (Action.forGesture(Gesture.VOLUME_UP) == LauncherAction.VOLUME_UP) { // Let the OS handle the key event. This works better with some custom ROMs @@ -207,29 +207,10 @@ class HomeActivity : UIObject, AppCompatActivity() { } override fun onTouchEvent(event: MotionEvent): Boolean { - return touchGestureDetector.onTouchEvent(event) || super.onTouchEvent(event) + touchGestureDetector?.onTouchEvent(event) + return true } - override fun setOnClicks() { - - binding.homeUpperView.setOnClickListener { - if (LauncherPreferences.clock().flipDateTime()) { - Gesture.TIME(this) - } else { - Gesture.DATE(this) - } - } - - binding.homeLowerView.setOnClickListener { - if (LauncherPreferences.clock().flipDateTime()) { - Gesture.DATE(this) - } else { - Gesture.TIME(this) - } - } - } - - private fun handleBack() { Gesture.BACK(this) } diff --git a/app/src/main/java/de/jrpie/android/launcher/ui/PinShortcutActivity.kt b/app/src/main/java/de/jrpie/android/launcher/ui/PinShortcutActivity.kt index d19fe04..3dbdda8 100644 --- a/app/src/main/java/de/jrpie/android/launcher/ui/PinShortcutActivity.kt +++ b/app/src/main/java/de/jrpie/android/launcher/ui/PinShortcutActivity.kt @@ -21,14 +21,16 @@ import de.jrpie.android.launcher.R import de.jrpie.android.launcher.actions.Action import de.jrpie.android.launcher.actions.Gesture import de.jrpie.android.launcher.actions.ShortcutAction -import de.jrpie.android.launcher.actions.shortcuts.PinnedShortcutInfo +import de.jrpie.android.launcher.apps.PinnedShortcutInfo import de.jrpie.android.launcher.databinding.ActivityPinShortcutBinding import de.jrpie.android.launcher.preferences.LauncherPreferences +import androidx.core.content.edit class PinShortcutActivity : AppCompatActivity(), UIObject { private lateinit var binding: ActivityPinShortcutBinding private var isBound = false + private var request: PinItemRequest? = null override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -46,7 +48,22 @@ class PinShortcutActivity : AppCompatActivity(), UIObject { val launcherApps = getSystemService(Service.LAUNCHER_APPS_SERVICE) as LauncherApps val request = launcherApps.getPinItemRequest(intent) - if (request == null || request.requestType != PinItemRequest.REQUEST_TYPE_SHORTCUT) { + this.request = request + if (request == null) { + finish() + return + } + + if (request.requestType == PinItemRequest.REQUEST_TYPE_APPWIDGET) { + + // TODO + request.getAppWidgetProviderInfo(this) + // startActivity() + finish() + return + } + + if (request.requestType != PinItemRequest.REQUEST_TYPE_SHORTCUT) { finish() return } @@ -70,9 +87,12 @@ class PinShortcutActivity : AppCompatActivity(), UIObject { isBound = true request.accept() } - val editor = LauncherPreferences.getSharedPreferences().edit() - ShortcutAction(PinnedShortcutInfo(request.shortcutInfo!!)).bindToGesture(editor, gesture.id) - editor.apply() + LauncherPreferences.getSharedPreferences().edit { + ShortcutAction(PinnedShortcutInfo(request.shortcutInfo!!)).bindToGesture( + this, + gesture.id + ) + } dialog.dismiss() } dialog.findViewById(R.id.dialog_select_gesture_recycler).apply { @@ -84,6 +104,7 @@ class PinShortcutActivity : AppCompatActivity(), UIObject { } binding.pinShortcutClose.setOnClickListener { finish() } + binding.pinShortcutButtonOk.setOnClickListener { finish() } } override fun onStart() { @@ -91,16 +112,34 @@ class PinShortcutActivity : AppCompatActivity(), UIObject { super.onStart() } + override fun onDestroy() { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) { + super.onDestroy() + return + } + if(binding.pinShortcutSwitchVisible.isChecked) { + if(!isBound) { + request?.accept() + } + request?.shortcutInfo?.let { + val set = LauncherPreferences.apps().pinnedShortcuts() ?: mutableSetOf() + set.add(PinnedShortcutInfo(it)) + LauncherPreferences.apps().pinnedShortcuts(set) + } + } + super.onDestroy() + } + override fun getTheme(): Resources.Theme { return modifyTheme(super.getTheme()) } inner class GestureRecyclerAdapter(val context: Context, val onClick: (Gesture) -> Unit): RecyclerView.Adapter() { - val gestures = Gesture.entries.filter { it.isEnabled() }.toList() + private val gestures = Gesture.entries.filter { it.isEnabled() }.toList() inner class ViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) { - val label = itemView.findViewById(R.id.dialog_select_gesture_row_name) - val description = itemView.findViewById(R.id.dialog_select_gesture_row_description) - val icon = itemView.findViewById(R.id.dialog_select_gesture_row_icon) + val label: TextView = itemView.findViewById(R.id.dialog_select_gesture_row_name) + val description: TextView = itemView.findViewById(R.id.dialog_select_gesture_row_description) + val icon: ImageView = itemView.findViewById(R.id.dialog_select_gesture_row_icon) } override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder { @@ -124,5 +163,6 @@ class PinShortcutActivity : AppCompatActivity(), UIObject { override fun getItemCount(): Int { return gestures.size } + } } \ No newline at end of file diff --git a/app/src/main/java/de/jrpie/android/launcher/ui/TouchGestureDetector.kt b/app/src/main/java/de/jrpie/android/launcher/ui/TouchGestureDetector.kt index 00629a5..8e8ed4e 100644 --- a/app/src/main/java/de/jrpie/android/launcher/ui/TouchGestureDetector.kt +++ b/app/src/main/java/de/jrpie/android/launcher/ui/TouchGestureDetector.kt @@ -1,8 +1,15 @@ package de.jrpie.android.launcher.ui import android.content.Context +import android.graphics.Insets +import android.os.Build +import android.os.Handler +import android.os.Looper +import android.util.DisplayMetrics import android.view.MotionEvent import android.view.ViewConfiguration +import android.view.WindowManager +import androidx.annotation.RequiresApi import de.jrpie.android.launcher.actions.Gesture import de.jrpie.android.launcher.preferences.LauncherPreferences import kotlin.math.abs @@ -12,8 +19,8 @@ import kotlin.math.tan class TouchGestureDetector( private val context: Context, - val width: Int, - val height: Int, + var width: Int, + var height: Int, var edgeWidth: Float ) { private val ANGULAR_THRESHOLD = tan(Math.PI / 6) @@ -27,20 +34,31 @@ class TouchGestureDetector( private val MIN_TRIANGLE_HEIGHT = 250 + private val longPressHandler = Handler(Looper.getMainLooper()) + + private var systemGestureInsetTop = 100 + private var systemGestureInsetBottom = 0 + private var systemGestureInsetLeft = 0 + private var systemGestureInsetRight = 0 + data class Vector(val x: Float, val y: Float) { fun absSquared(): Float { return this.x * this.x + this.y * this.y } + fun plus(vector: Vector): Vector { return Vector(this.x + vector.x, this.y + vector.y) } + fun max(other: Vector): Vector { return Vector(max(this.x, other.x), max(this.y, other.y)) } + fun min(other: Vector): Vector { return Vector(min(this.x, other.x), min(this.y, other.y)) } + operator fun minus(vector: Vector): Vector { return Vector(this.x - vector.x, this.y - vector.y) } @@ -57,16 +75,35 @@ class TouchGestureDetector( fun sizeSquared(): Float { return (max - min).absSquared() } + fun getDirection(): Vector { return last - start } + fun update(vector: Vector) { min = min.min(vector) max = max.max(vector) last = vector } } + + private fun PointerPath.startIntersectsSystemGestureInsets(): Boolean { + // ignore x, since this makes edge swipes very hard to execute + return start.y < systemGestureInsetTop + || start.y > height - systemGestureInsetBottom + } + + private fun PointerPath.intersectsSystemGestureInsets(): Boolean { + return min.x < systemGestureInsetLeft + || min.y < systemGestureInsetTop + || max.x > width - systemGestureInsetRight + || max.y > height - systemGestureInsetBottom + } + private fun PointerPath.isTap(): Boolean { + if (intersectsSystemGestureInsets()) { + return false + } return sizeSquared() < TOUCH_SLOP_SQUARE } @@ -84,20 +121,48 @@ class TouchGestureDetector( private var paths = HashMap() + /* Set when + * - the longPressHandler has detected this gesture as a long press + * - the gesture was cancelled by MotionEvent.ACTION_CANCEL + * In any case, the current gesture should be ignored by further detection logic. + */ + private var cancelled = false + private var lastTappedTime = 0L private var lastTappedLocation: Vector? = null - fun onTouchEvent(event: MotionEvent): Boolean { + fun onTouchEvent(event: MotionEvent) { + + if (event.actionMasked == MotionEvent.ACTION_CANCEL) { + synchronized(this@TouchGestureDetector) { + cancelled = true + } + } + val pointerIdToIndex = (0.. LONG_PRESS_TIMEOUT) { - // TODO: Don't wait until the finger is lifted. - // Instead set a timer to start long click as soon as LONG_PRESS_TIMEOUT is reached - Gesture.LONG_CLICK.invoke(context) } } else { // detect swipes @@ -197,34 +266,38 @@ class TouchGestureDetector( val startEndMax = mainPointerPath.start.max(mainPointerPath.last) when (gesture) { Gesture.SWIPE_DOWN -> { - if(startEndMax.x + MIN_TRIANGLE_HEIGHT < mainPointerPath.max.x) { + if (startEndMax.x + MIN_TRIANGLE_HEIGHT < mainPointerPath.max.x) { gesture = Gesture.SWIPE_LARGER } else if (startEndMin.x - MIN_TRIANGLE_HEIGHT > mainPointerPath.min.x) { gesture = Gesture.SWIPE_SMALLER } } + Gesture.SWIPE_UP -> { - if(startEndMax.x + MIN_TRIANGLE_HEIGHT < mainPointerPath.max.x) { + if (startEndMax.x + MIN_TRIANGLE_HEIGHT < mainPointerPath.max.x) { gesture = Gesture.SWIPE_LARGER_REVERSE } else if (startEndMin.x - MIN_TRIANGLE_HEIGHT > mainPointerPath.min.x) { gesture = Gesture.SWIPE_SMALLER_REVERSE } } + Gesture.SWIPE_RIGHT -> { - if(startEndMax.y + MIN_TRIANGLE_HEIGHT < mainPointerPath.max.y) { + if (startEndMax.y + MIN_TRIANGLE_HEIGHT < mainPointerPath.max.y) { gesture = Gesture.SWIPE_V } else if (startEndMin.y - MIN_TRIANGLE_HEIGHT > mainPointerPath.min.y) { gesture = Gesture.SWIPE_LAMBDA } } + Gesture.SWIPE_LEFT -> { - if(startEndMax.y + MIN_TRIANGLE_HEIGHT < mainPointerPath.max.y) { + if (startEndMax.y + MIN_TRIANGLE_HEIGHT < mainPointerPath.max.y) { gesture = Gesture.SWIPE_V_REVERSE } else if (startEndMin.y - MIN_TRIANGLE_HEIGHT > mainPointerPath.min.y) { gesture = Gesture.SWIPE_LAMBDA_REVERSE } } - else -> { } + + else -> {} } if (edgeActions) { @@ -247,4 +320,20 @@ class TouchGestureDetector( gesture?.invoke(context) } } + + fun updateScreenSize(windowManager: WindowManager) { + val displayMetrics = DisplayMetrics() + @Suppress("deprecation") // required to support API < 30 + windowManager.defaultDisplay.getMetrics(displayMetrics) + width = displayMetrics.widthPixels + height = displayMetrics.heightPixels + } + + @RequiresApi(Build.VERSION_CODES.Q) + fun setSystemGestureInsets(insets: Insets) { + systemGestureInsetTop = insets.top + systemGestureInsetBottom = insets.bottom + systemGestureInsetLeft = insets.left + systemGestureInsetRight = insets.right + } } \ No newline at end of file diff --git a/app/src/main/java/de/jrpie/android/launcher/ui/UIObject.kt b/app/src/main/java/de/jrpie/android/launcher/ui/UIObject.kt index 3702bb2..51324f4 100644 --- a/app/src/main/java/de/jrpie/android/launcher/ui/UIObject.kt +++ b/app/src/main/java/de/jrpie/android/launcher/ui/UIObject.kt @@ -3,7 +3,11 @@ package de.jrpie.android.launcher.ui import android.app.Activity import android.content.pm.ActivityInfo import android.content.res.Resources +import android.os.Build +import android.view.View import android.view.Window +import android.view.WindowInsets +import android.view.WindowInsetsController import android.view.WindowManager import de.jrpie.android.launcher.preferences.LauncherPreferences @@ -11,10 +15,12 @@ import de.jrpie.android.launcher.preferences.LauncherPreferences * An interface implemented by every [Activity], Fragment etc. in Launcher. * It handles themes and window flags - a useful abstraction as it is the same everywhere. */ +@Suppress("deprecation") // FLAG_FULLSCREEN is required to support API level < 30 fun setWindowFlags(window: Window, homeScreen: Boolean) { window.setFlags(0, 0) // clear flags + // Display notification bar - if (LauncherPreferences.display().fullScreen()) + if (LauncherPreferences.display().hideStatusBar()) window.setFlags( WindowManager.LayoutParams.FLAG_FULLSCREEN, WindowManager.LayoutParams.FLAG_FULLSCREEN @@ -36,17 +42,19 @@ fun setWindowFlags(window: Window, homeScreen: Boolean) { } + interface UIObject { fun onCreate() { - if (this is Activity) { - setWindowFlags(window, isHomeScreen()) - - if (!LauncherPreferences.display().rotateScreen()) { - requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_NOSENSOR - } + if (this !is Activity) { + return + } + setWindowFlags(window, isHomeScreen()) + if (!LauncherPreferences.display().rotateScreen()) { + requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_NOSENSOR } } + fun onStart() { setOnClicks() adjustLayout() @@ -70,4 +78,26 @@ interface UIObject { fun isHomeScreen(): Boolean { return false } + + + @Suppress("DEPRECATION") + fun hideNavigationBar() { + if (this !is Activity) { + return + } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + window.insetsController?.apply { + hide(WindowInsets.Type.navigationBars()) + systemBarsBehavior = + WindowInsetsController.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE + } + } else { + // Try to hide the navigation bar but do not hide the status bar + window.decorView.systemUiVisibility = (View.SYSTEM_UI_FLAG_HIDE_NAVIGATION + or View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION + or View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY + or View.SYSTEM_UI_FLAG_IMMERSIVE + or View.SYSTEM_UI_FLAG_LAYOUT_STABLE) + } + } } \ No newline at end of file diff --git a/app/src/main/java/de/jrpie/android/launcher/ui/list/ListActivity.kt b/app/src/main/java/de/jrpie/android/launcher/ui/list/ListActivity.kt index c4ecded..fe27f0f 100644 --- a/app/src/main/java/de/jrpie/android/launcher/ui/list/ListActivity.kt +++ b/app/src/main/java/de/jrpie/android/launcher/ui/list/ListActivity.kt @@ -1,27 +1,20 @@ package de.jrpie.android.launcher.ui.list -import android.app.Activity -import android.content.Context -import android.content.Intent import android.content.res.Resources import android.graphics.Rect import android.os.Build import android.os.Bundle import android.view.View -import android.widget.Toast import android.window.OnBackInvokedDispatcher import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.content.res.AppCompatResources import androidx.fragment.app.Fragment -import androidx.fragment.app.FragmentManager import androidx.fragment.app.FragmentPagerAdapter -import androidx.viewpager.widget.ViewPager -import com.google.android.material.tabs.TabLayout import de.jrpie.android.launcher.Application import de.jrpie.android.launcher.R -import de.jrpie.android.launcher.REQUEST_UNINSTALL import de.jrpie.android.launcher.actions.LauncherAction import de.jrpie.android.launcher.apps.AppFilter +import de.jrpie.android.launcher.apps.hidePrivateSpaceWhenLocked import de.jrpie.android.launcher.apps.isPrivateSpaceLocked import de.jrpie.android.launcher.apps.isPrivateSpaceSetUp import de.jrpie.android.launcher.apps.togglePrivateSpaceLock @@ -32,14 +25,6 @@ import de.jrpie.android.launcher.ui.list.apps.ListFragmentApps import de.jrpie.android.launcher.ui.list.other.ListFragmentOther -// TODO: Better solution for this intercommunication functionality (used in list-fragments) -var intention = ListActivity.ListActivityIntention.VIEW -var favoritesVisibility: AppFilter.Companion.AppSetVisibility = AppFilter.Companion.AppSetVisibility.VISIBLE -var privateSpaceVisibility: AppFilter.Companion.AppSetVisibility = - AppFilter.Companion.AppSetVisibility.VISIBLE -var hiddenVisibility: AppFilter.Companion.AppSetVisibility = AppFilter.Companion.AppSetVisibility.HIDDEN -var forGesture: String? = null - /** * The [ListActivity] is the most general purpose activity in Launcher: * - used to view all apps and edit their settings @@ -49,9 +34,34 @@ var forGesture: String? = null */ class ListActivity : AppCompatActivity(), UIObject { private lateinit var binding: ListBinding + var intention = ListActivityIntention.VIEW + var favoritesVisibility: AppFilter.Companion.AppSetVisibility = + AppFilter.Companion.AppSetVisibility.VISIBLE + var privateSpaceVisibility: AppFilter.Companion.AppSetVisibility = + AppFilter.Companion.AppSetVisibility.VISIBLE + var hiddenVisibility: AppFilter.Companion.AppSetVisibility = + AppFilter.Companion.AppSetVisibility.HIDDEN + var forGesture: String? = null private fun updateLockIcon(locked: Boolean) { + if ( + // only show lock for VIEW intention + (intention != ListActivityIntention.VIEW) + // hide lock when private space does not exist + || !isPrivateSpaceSetUp(this) + // hide lock when private space apps are hidden from the main list and we are not in the private space list + || (LauncherPreferences.apps().hidePrivateSpaceApps() + && privateSpaceVisibility != AppFilter.Companion.AppSetVisibility.EXCLUSIVE) + // hide lock when private space is locked and the hidden when locked setting is set + || (locked && hidePrivateSpaceWhenLocked(this)) + ) { + binding.listLock.visibility = View.GONE + return + } + + binding.listLock.visibility = View.VISIBLE + binding.listLock.setImageDrawable( AppCompatResources.getDrawable( this, @@ -74,7 +84,6 @@ class ListActivity : AppCompatActivity(), UIObject { } - enum class ListActivityIntention(val titleResource: Int) { VIEW(R.string.list_title_view), /* view list of apps */ PICK(R.string.list_title_pick) /* choose app or action to associate to a gesture */ @@ -99,10 +108,13 @@ class ListActivity : AppCompatActivity(), UIObject { ?.let { ListActivityIntention.valueOf(it) } ?: ListActivityIntention.VIEW + @Suppress("deprecation") // required to support API level < 33 favoritesVisibility = bundle.getSerializable("favoritesVisibility") as? AppFilter.Companion.AppSetVisibility ?: favoritesVisibility + @Suppress("deprecation") // required to support API level < 33 privateSpaceVisibility = bundle.getSerializable("privateSpaceVisibility") as? AppFilter.Companion.AppSetVisibility ?: privateSpaceVisibility + @Suppress("deprecation") // required to support API level < 33 hiddenVisibility = bundle.getSerializable("hiddenVisibility") as? AppFilter.Companion.AppSetVisibility ?: hiddenVisibility @@ -119,20 +131,6 @@ class ListActivity : AppCompatActivity(), UIObject { LauncherAction.SETTINGS.launch(this@ListActivity) } - binding.listLock.visibility = - if (intention != ListActivityIntention.VIEW) { - View.GONE - } else if (!isPrivateSpaceSetUp(this)) { - View.GONE - } else if (LauncherPreferences.apps().hidePrivateSpaceApps()) { - if (privateSpaceVisibility == AppFilter.Companion.AppSetVisibility.EXCLUSIVE) { - View.VISIBLE - } else { - View.GONE - } - } else { - View.VISIBLE - } if (privateSpaceVisibility == AppFilter.Companion.AppSetVisibility.EXCLUSIVE) { isPrivateSpaceSetUp(this, showToast = true, launchSettings = true) @@ -155,7 +153,7 @@ class ListActivity : AppCompatActivity(), UIObject { binding.listContainer.context.resources.displayMetrics.heightPixels val diff = height - r.bottom if (diff != 0 && - LauncherPreferences.display().fullScreen() + LauncherPreferences.display().hideStatusBar() ) { if (binding.listContainer.paddingBottom != diff) { binding.listContainer.setPadding(0, 0, 0, diff) @@ -183,32 +181,19 @@ class ListActivity : AppCompatActivity(), UIObject { finish() } - override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { - super.onActivityResult(requestCode, resultCode, data) - if (requestCode == REQUEST_UNINSTALL) { - if (resultCode == Activity.RESULT_OK) { - Toast.makeText(this, getString(R.string.list_removed), Toast.LENGTH_LONG).show() - finish() - } else if (resultCode == Activity.RESULT_FIRST_USER) { - Toast.makeText(this, getString(R.string.list_not_removed), Toast.LENGTH_LONG).show() - finish() - } - } - } - - fun updateTitle() { var titleResource = intention.titleResource if (intention == ListActivityIntention.VIEW) { - titleResource = if (hiddenVisibility == AppFilter.Companion.AppSetVisibility.EXCLUSIVE) { - R.string.list_title_hidden - } else if (privateSpaceVisibility == AppFilter.Companion.AppSetVisibility.EXCLUSIVE) { - R.string.list_title_private_space - } else if (favoritesVisibility == AppFilter.Companion.AppSetVisibility.EXCLUSIVE) { - R.string.list_title_favorite - } else { - R.string.list_title_view - } + titleResource = + if (hiddenVisibility == AppFilter.Companion.AppSetVisibility.EXCLUSIVE) { + R.string.list_title_hidden + } else if (privateSpaceVisibility == AppFilter.Companion.AppSetVisibility.EXCLUSIVE) { + R.string.list_title_private_space + } else if (favoritesVisibility == AppFilter.Companion.AppSetVisibility.EXCLUSIVE) { + R.string.list_title_favorite + } else { + R.string.list_title_view + } } binding.listHeading.text = getString(titleResource) @@ -238,11 +223,11 @@ class ListActivity : AppCompatActivity(), UIObject { updateTitle() - val sectionsPagerAdapter = ListSectionsPagerAdapter(this, supportFragmentManager) - val viewPager: ViewPager = findViewById(R.id.list_viewpager) - viewPager.adapter = sectionsPagerAdapter - val tabs: TabLayout = findViewById(R.id.list_tabs) - tabs.setupWithViewPager(viewPager) + val sectionsPagerAdapter = ListSectionsPagerAdapter(this) + binding.listViewpager.let { + it.adapter = sectionsPagerAdapter + binding.listTabs.setupWithViewPager(it) + } } } @@ -254,9 +239,15 @@ private val TAB_TITLES = arrayOf( /** * The [ListSectionsPagerAdapter] returns the fragment, * which corresponds to the selected tab in [ListActivity]. + * + * This should eventually be replaced by a [FragmentStateAdapter] + * However this keyboard does not open when using [ViewPager2] + * so currently [ViewPager] is used here. + * https://github.com/jrpie/launcher/issues/130 */ -class ListSectionsPagerAdapter(private val context: Context, fm: FragmentManager) : - FragmentPagerAdapter(fm, BEHAVIOR_RESUME_ONLY_CURRENT_FRAGMENT) { +@Suppress("deprecation") +class ListSectionsPagerAdapter(private val activity: ListActivity) : + FragmentPagerAdapter(activity.supportFragmentManager) { override fun getItem(position: Int): Fragment { return when (position) { @@ -267,11 +258,11 @@ class ListSectionsPagerAdapter(private val context: Context, fm: FragmentManager } override fun getPageTitle(position: Int): CharSequence { - return context.resources.getString(TAB_TITLES[position]) + return activity.resources.getString(TAB_TITLES[position]) } override fun getCount(): Int { - return when (intention) { + return when (activity.intention) { ListActivity.ListActivityIntention.VIEW -> 1 else -> 2 } diff --git a/app/src/main/java/de/jrpie/android/launcher/ui/list/apps/AppsRecyclerAdapter.kt b/app/src/main/java/de/jrpie/android/launcher/ui/list/apps/AppsRecyclerAdapter.kt index 2d8e1eb..65278ce 100644 --- a/app/src/main/java/de/jrpie/android/launcher/ui/list/apps/AppsRecyclerAdapter.kt +++ b/app/src/main/java/de/jrpie/android/launcher/ui/list/apps/AppsRecyclerAdapter.kt @@ -2,7 +2,6 @@ package de.jrpie.android.launcher.ui.list.apps import android.annotation.SuppressLint import android.app.Activity -import android.content.Intent import android.graphics.Rect import android.view.LayoutInflater import android.view.View @@ -15,12 +14,12 @@ import androidx.appcompat.app.AppCompatActivity import androidx.recyclerview.widget.RecyclerView import de.jrpie.android.launcher.Application import de.jrpie.android.launcher.R -import de.jrpie.android.launcher.REQUEST_CHOOSE_APP -import de.jrpie.android.launcher.actions.AppAction +import de.jrpie.android.launcher.actions.Action +import de.jrpie.android.launcher.actions.Gesture +import de.jrpie.android.launcher.apps.AbstractDetailedAppInfo import de.jrpie.android.launcher.apps.AppFilter import de.jrpie.android.launcher.apps.AppInfo import de.jrpie.android.launcher.apps.DetailedAppInfo -import de.jrpie.android.launcher.getUserFromId import de.jrpie.android.launcher.preferences.LauncherPreferences import de.jrpie.android.launcher.preferences.ListLayout import de.jrpie.android.launcher.ui.list.ListActivity @@ -47,7 +46,8 @@ class AppsRecyclerAdapter( RecyclerView.Adapter() { private val apps = (activity.applicationContext as Application).apps - private val appsListDisplayed: MutableList = mutableListOf() + private val appsListDisplayed: MutableList = mutableListOf() + private val grayscale = LauncherPreferences.theme().monochromeIcons() // temporarily disable auto launch var disableAutoLaunch: Boolean = false @@ -68,7 +68,7 @@ class AppsRecyclerAdapter( override fun onClick(v: View) { val rect = Rect() img.getGlobalVisibleRect(rect) - selectItem(adapterPosition, rect) + selectItem(bindingAdapterPosition, rect) } init { @@ -80,20 +80,19 @@ class AppsRecyclerAdapter( override fun onBindViewHolder(viewHolder: ViewHolder, i: Int) { var appLabel = appsListDisplayed[i].getCustomLabel(activity) + val appIcon = appsListDisplayed[i].getIcon(activity) + + viewHolder.img.transformGrayscale(grayscale) + viewHolder.img.setImageDrawable(appIcon.constantState?.newDrawable() ?: appIcon) + if (layout.useBadgedText) { appLabel = activity.packageManager.getUserBadgedLabel( appLabel, - getUserFromId(appsListDisplayed[i].app.user, activity) + appsListDisplayed[i].getUser(activity) ).toString() } - - val appIcon = appsListDisplayed[i].icon - viewHolder.textView.text = appLabel - viewHolder.img.setImageDrawable(appIcon) - if (LauncherPreferences.theme().monochromeIcons()) - viewHolder.img.transformGrayscale() // decide when to show the options popup menu about if (intention == ListActivity.ListActivityIntention.VIEW) { @@ -118,22 +117,26 @@ class AppsRecyclerAdapter( @Suppress("SameReturnValue") private fun showOptionsPopup( viewHolder: ViewHolder, - appInfo: DetailedAppInfo + appInfo: AbstractDetailedAppInfo ): Boolean { //create the popup menu val popup = PopupMenu(activity, viewHolder.img) popup.inflate(R.menu.menu_app) - if (appInfo.isSystemApp) { + if (!appInfo.isRemovable()) { popup.menu.findItem(R.id.app_menu_delete).setVisible(false) } - if (LauncherPreferences.apps().hidden()?.contains(appInfo.app) == true) { + if (appInfo !is DetailedAppInfo) { + popup.menu.findItem(R.id.app_menu_info).setVisible(false) + } + + if (LauncherPreferences.apps().hidden()?.contains(appInfo.getRawInfo()) == true) { popup.menu.findItem(R.id.app_menu_hidden).setTitle(R.string.list_app_hidden_remove) } - if (LauncherPreferences.apps().favorites()?.contains(appInfo.app) == true) { + if (LauncherPreferences.apps().favorites()?.contains(appInfo.getRawInfo()) == true) { popup.menu.findItem(R.id.app_menu_favorite).setTitle(R.string.list_app_favorite_remove) } @@ -141,19 +144,19 @@ class AppsRecyclerAdapter( popup.setOnMenuItemClickListener { when (it.itemId) { R.id.app_menu_delete -> { - appInfo.app.uninstall(activity); true + appInfo.getRawInfo().uninstall(activity); true } R.id.app_menu_info -> { - appInfo.app.openSettings(activity); true + (appInfo.getRawInfo() as? AppInfo)?.openSettings(activity); true } R.id.app_menu_favorite -> { - appInfo.app.toggleFavorite(); true + appInfo.getRawInfo().toggleFavorite(); true } R.id.app_menu_hidden -> { - appInfo.app.toggleHidden(root); true + appInfo.getRawInfo().toggleHidden(root); true } R.id.app_menu_rename -> { @@ -188,15 +191,14 @@ class AppsRecyclerAdapter( val appInfo = appsListDisplayed[pos] when (intention) { ListActivity.ListActivityIntention.VIEW -> { - AppAction(appInfo.app).invoke(activity, rect) + appInfo.getAction().invoke(activity, rect) } ListActivity.ListActivityIntention.PICK -> { - val returnIntent = Intent() - AppAction(appInfo.app).writeToIntent(returnIntent) - returnIntent.putExtra("forGesture", forGesture) - activity.setResult(REQUEST_CHOOSE_APP, returnIntent) activity.finish() + forGesture ?: return + val gesture = Gesture.byId(forGesture) ?: return + Action.setActionForGesture(gesture, appInfo.getAction()) } } } @@ -211,8 +213,8 @@ class AppsRecyclerAdapter( && !disableAutoLaunch && LauncherPreferences.functionality().searchAutoLaunch() ) { - val info = appsListDisplayed[0] - AppAction(info.app).invoke(activity) + val app = appsListDisplayed[0] + app.getAction().invoke(activity) val inputMethodManager = activity.getSystemService(Activity.INPUT_METHOD_SERVICE) as InputMethodManager diff --git a/app/src/main/java/de/jrpie/android/launcher/ui/list/apps/ContextMenuActions.kt b/app/src/main/java/de/jrpie/android/launcher/ui/list/apps/ContextMenuActions.kt index 9636dc2..22dff02 100644 --- a/app/src/main/java/de/jrpie/android/launcher/ui/list/apps/ContextMenuActions.kt +++ b/app/src/main/java/de/jrpie/android/launcher/ui/list/apps/ContextMenuActions.kt @@ -1,11 +1,11 @@ package de.jrpie.android.launcher.ui.list.apps +import android.app.Activity import android.app.Service import android.content.Context import android.content.Intent import android.content.pm.LauncherApps import android.graphics.Rect -import android.net.Uri import android.os.Bundle import android.util.Log import android.view.View @@ -13,11 +13,13 @@ import android.widget.EditText import androidx.appcompat.app.AlertDialog import com.google.android.material.snackbar.Snackbar import de.jrpie.android.launcher.R -import de.jrpie.android.launcher.REQUEST_UNINSTALL import de.jrpie.android.launcher.apps.AppInfo -import de.jrpie.android.launcher.apps.DetailedAppInfo +import de.jrpie.android.launcher.apps.AbstractAppInfo +import de.jrpie.android.launcher.apps.AbstractDetailedAppInfo +import de.jrpie.android.launcher.apps.PinnedShortcutInfo import de.jrpie.android.launcher.getUserFromId import de.jrpie.android.launcher.preferences.LauncherPreferences +import androidx.core.net.toUri private const val LOG_TAG = "AppContextMenu" @@ -32,27 +34,29 @@ fun AppInfo.openSettings( } } -fun AppInfo.uninstall(activity: android.app.Activity) { - val packageName = this.packageName - val userId = this.user +fun AbstractAppInfo.uninstall(activity: Activity) { + if (this is AppInfo) { + val packageName = this.packageName + val userId = this.user - Log.i(LOG_TAG, "uninstalling $this") + Log.i(LOG_TAG, "uninstalling $this") - val intent = Intent(Intent.ACTION_UNINSTALL_PACKAGE) - intent.data = Uri.parse("package:$packageName") - getUserFromId(userId, activity).let { user -> - intent.putExtra(Intent.EXTRA_USER, user) + val intent = Intent(Intent.ACTION_DELETE) + intent.data = "package:$packageName".toUri() + getUserFromId(userId, activity).let { user -> + intent.putExtra(Intent.EXTRA_USER, user) + } + activity.startActivity(intent) + + } else if(this is PinnedShortcutInfo) { + val pinned = LauncherPreferences.apps().pinnedShortcuts() ?: mutableSetOf() + pinned.remove(this) + LauncherPreferences.apps().pinnedShortcuts(pinned) } - - intent.putExtra(Intent.EXTRA_RETURN_RESULT, true) - activity.startActivityForResult( - intent, - REQUEST_UNINSTALL - ) } -fun AppInfo.toggleFavorite() { - val favorites: MutableSet = +fun AbstractAppInfo.toggleFavorite() { + val favorites: MutableSet = LauncherPreferences.apps().favorites() ?: mutableSetOf() if (favorites.contains(this)) { @@ -69,8 +73,8 @@ fun AppInfo.toggleFavorite() { /** * @param view: used to show a snackbar letting the user undo the action */ -fun AppInfo.toggleHidden(view: View) { - val hidden: MutableSet = +fun AbstractAppInfo.toggleHidden(view: View) { + val hidden: MutableSet = LauncherPreferences.apps().hidden() ?: mutableSetOf() if (hidden.contains(this)) { hidden.remove(this) @@ -87,12 +91,12 @@ fun AppInfo.toggleHidden(view: View) { LauncherPreferences.apps().hidden(hidden) } -fun DetailedAppInfo.showRenameDialog(context: Context) { +fun AbstractDetailedAppInfo.showRenameDialog(context: Context) { AlertDialog.Builder(context, R.style.AlertDialogCustom).apply { - setTitle(context.getString(R.string.dialog_rename_title, label)) + setTitle(context.getString(R.string.dialog_rename_title, getLabel())) setView(R.layout.dialog_rename_app) - setNegativeButton(R.string.dialog_cancel) { d, _ -> d.cancel() } - setPositiveButton(R.string.dialog_rename_ok) { d, _ -> + setNegativeButton(android.R.string.cancel) { d, _ -> d.cancel() } + setPositiveButton(android.R.string.ok) { d, _ -> setCustomLabel( (d as? AlertDialog) ?.findViewById(R.id.dialog_rename_app_edit_text) @@ -102,7 +106,7 @@ fun DetailedAppInfo.showRenameDialog(context: Context) { }.create().also { it.show() }.apply { val input = findViewById(R.id.dialog_rename_app_edit_text) input?.setText(getCustomLabel(context)) - input?.hint = label + input?.hint = getLabel() } } diff --git a/app/src/main/java/de/jrpie/android/launcher/ui/list/apps/ListFragmentApps.kt b/app/src/main/java/de/jrpie/android/launcher/ui/list/apps/ListFragmentApps.kt index 3a6e403..a8e59ba 100644 --- a/app/src/main/java/de/jrpie/android/launcher/ui/list/apps/ListFragmentApps.kt +++ b/app/src/main/java/de/jrpie/android/launcher/ui/list/apps/ListFragmentApps.kt @@ -9,18 +9,18 @@ import android.view.View import android.view.ViewGroup import android.widget.Toast import androidx.fragment.app.Fragment +import androidx.recyclerview.widget.GridLayoutManager +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView import de.jrpie.android.launcher.R import de.jrpie.android.launcher.apps.AppFilter import de.jrpie.android.launcher.databinding.ListAppsBinding import de.jrpie.android.launcher.preferences.LauncherPreferences import de.jrpie.android.launcher.ui.UIObject +import de.jrpie.android.launcher.ui.closeSoftKeyboard import de.jrpie.android.launcher.ui.list.ListActivity -import de.jrpie.android.launcher.ui.list.favoritesVisibility -import de.jrpie.android.launcher.ui.list.forGesture -import de.jrpie.android.launcher.ui.list.hiddenVisibility -import de.jrpie.android.launcher.ui.list.intention -import de.jrpie.android.launcher.ui.list.privateSpaceVisibility import de.jrpie.android.launcher.ui.openSoftKeyboard +import kotlin.math.absoluteValue /** @@ -52,7 +52,7 @@ class ListFragmentApps : Fragment(), UIObject { .registerOnSharedPreferenceChangeListener(sharedPreferencesListener) binding.listAppsCheckBoxFavorites.isChecked = - (favoritesVisibility == AppFilter.Companion.AppSetVisibility.EXCLUSIVE) + ((activity as? ListActivity)?.favoritesVisibility == AppFilter.Companion.AppSetVisibility.EXCLUSIVE) } override fun onStop() { @@ -65,26 +65,48 @@ class ListFragmentApps : Fragment(), UIObject { override fun setOnClicks() {} override fun adjustLayout() { + val listActivity = activity as? ListActivity ?: return appsRecyclerAdapter = AppsRecyclerAdapter( - requireActivity(), binding.root, intention, forGesture, + listActivity, binding.root, listActivity.intention, listActivity.forGesture, appFilter = AppFilter( requireContext(), "", - favoritesVisibility = favoritesVisibility, - privateSpaceVisibility = privateSpaceVisibility, - hiddenVisibility = hiddenVisibility + favoritesVisibility = listActivity.favoritesVisibility, + privateSpaceVisibility = listActivity.privateSpaceVisibility, + hiddenVisibility = listActivity.hiddenVisibility ), layout = LauncherPreferences.list().layout() ) + // set up the list / recycler binding.listAppsRview.apply { // improve performance (since content changes don't change the layout size) setHasFixedSize(true) layoutManager = LauncherPreferences.list().layout().layoutManager(context) + .also { + if (LauncherPreferences.list().reverseLayout()) { + (it as? LinearLayoutManager)?.reverseLayout = true + (it as? GridLayoutManager)?.reverseLayout = true + } + } adapter = appsRecyclerAdapter + if (LauncherPreferences.functionality().searchAutoCloseKeyboard()) { + addOnScrollListener(object : RecyclerView.OnScrollListener() { + var totalDy: Int = 0 + var threshold = (resources.displayMetrics.density * 100).toInt() + override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) { + totalDy += dy + + if (totalDy.absoluteValue > 100) { + totalDy = 0 + closeSoftKeyboard(requireActivity()) + } + } + }) + } } binding.listAppsSearchview.setOnQueryTextListener(object : @@ -115,7 +137,8 @@ class ListFragmentApps : Fragment(), UIObject { if (newText == " " && !appsRecyclerAdapter.disableAutoLaunch && - intention == ListActivity.ListActivityIntention.VIEW && + (activity as? ListActivity)?.intention + == ListActivity.ListActivityIntention.VIEW && LauncherPreferences.functionality().searchAutoLaunch() ) { appsRecyclerAdapter.disableAutoLaunch = true @@ -132,17 +155,17 @@ class ListFragmentApps : Fragment(), UIObject { }) binding.listAppsCheckBoxFavorites.setOnClickListener { - favoritesVisibility = + listActivity.favoritesVisibility = if (binding.listAppsCheckBoxFavorites.isChecked) { AppFilter.Companion.AppSetVisibility.EXCLUSIVE } else { AppFilter.Companion.AppSetVisibility.VISIBLE } - appsRecyclerAdapter.setFavoritesVisibility(favoritesVisibility) + appsRecyclerAdapter.setFavoritesVisibility(listActivity.favoritesVisibility) (activity as? ListActivity)?.updateTitle() } - if (intention == ListActivity.ListActivityIntention.VIEW + if (listActivity.intention == ListActivity.ListActivityIntention.VIEW && LauncherPreferences.functionality().searchAutoOpenKeyboard() ) { binding.listAppsSearchview.openSoftKeyboard(requireContext()) diff --git a/app/src/main/java/de/jrpie/android/launcher/ui/list/other/OtherRecyclerAdapter.kt b/app/src/main/java/de/jrpie/android/launcher/ui/list/other/OtherRecyclerAdapter.kt index 97d1c84..06be78a 100644 --- a/app/src/main/java/de/jrpie/android/launcher/ui/list/other/OtherRecyclerAdapter.kt +++ b/app/src/main/java/de/jrpie/android/launcher/ui/list/other/OtherRecyclerAdapter.kt @@ -1,7 +1,6 @@ package de.jrpie.android.launcher.ui.list.other import android.app.Activity -import android.content.Intent import android.view.LayoutInflater import android.view.View import android.view.ViewGroup @@ -9,9 +8,11 @@ import android.widget.ImageView import android.widget.TextView import androidx.recyclerview.widget.RecyclerView import de.jrpie.android.launcher.R -import de.jrpie.android.launcher.REQUEST_CHOOSE_APP +import de.jrpie.android.launcher.actions.Action +import de.jrpie.android.launcher.actions.Gesture import de.jrpie.android.launcher.actions.LauncherAction -import de.jrpie.android.launcher.ui.list.forGesture +import de.jrpie.android.launcher.actions.WidgetPanelAction +import de.jrpie.android.launcher.ui.list.ListActivity /** * The [OtherRecyclerAdapter] will only be displayed in the ListActivity, @@ -23,8 +24,10 @@ import de.jrpie.android.launcher.ui.list.forGesture class OtherRecyclerAdapter(val activity: Activity) : RecyclerView.Adapter() { - private val othersList: Array = - LauncherAction.entries.filter { it.isAvailable(activity) }.toTypedArray() + private val othersList: Array = + LauncherAction.entries.filter { it.isAvailable(activity) } + .plus(WidgetPanelAction(-1)) + .toTypedArray() inner class ViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView), View.OnClickListener { @@ -33,10 +36,15 @@ class OtherRecyclerAdapter(val activity: Activity) : override fun onClick(v: View) { - val pos = adapterPosition + val pos = bindingAdapterPosition val content = othersList[pos] - forGesture?.let { returnChoiceIntent(it, content) } + val gestureId = (activity as? ListActivity)?.forGesture ?: return + val gesture = Gesture.byId(gestureId) ?: return + content.showConfigurationDialog(activity) { configuredAction -> + Action.setActionForGesture(gesture, configuredAction) + activity.finish() + } } init { @@ -45,11 +53,11 @@ class OtherRecyclerAdapter(val activity: Activity) : } override fun onBindViewHolder(viewHolder: ViewHolder, i: Int) { - val otherLabel = activity.getString(othersList[i].label) - val icon = othersList[i].icon + val otherLabel = othersList[i].label(activity) + val icon = othersList[i].getIcon(activity) viewHolder.textView.text = otherLabel - viewHolder.iconView.setImageResource(icon) + viewHolder.iconView.setImageDrawable(icon) } override fun getItemCount(): Int { @@ -61,12 +69,4 @@ class OtherRecyclerAdapter(val activity: Activity) : val view: View = inflater.inflate(R.layout.list_other_row, parent, false) return ViewHolder(view) } - - private fun returnChoiceIntent(forGesture: String, action: LauncherAction) { - val returnIntent = Intent() - returnIntent.putExtra("forGesture", forGesture) - action.writeToIntent(returnIntent) - activity.setResult(REQUEST_CHOOSE_APP, returnIntent) - activity.finish() - } } \ No newline at end of file diff --git a/app/src/main/java/de/jrpie/android/launcher/ui/settings/SettingsActivity.kt b/app/src/main/java/de/jrpie/android/launcher/ui/settings/SettingsActivity.kt index fde61a7..cd59726 100644 --- a/app/src/main/java/de/jrpie/android/launcher/ui/settings/SettingsActivity.kt +++ b/app/src/main/java/de/jrpie/android/launcher/ui/settings/SettingsActivity.kt @@ -1,6 +1,5 @@ package de.jrpie.android.launcher.ui.settings -import android.content.Context import android.content.Intent import android.content.SharedPreferences import android.content.res.Resources @@ -8,17 +7,14 @@ import android.os.Bundle import android.provider.Settings import androidx.appcompat.app.AppCompatActivity import androidx.fragment.app.Fragment -import androidx.fragment.app.FragmentManager -import androidx.fragment.app.FragmentPagerAdapter -import androidx.viewpager.widget.ViewPager -import com.google.android.material.tabs.TabLayout +import androidx.fragment.app.FragmentActivity +import androidx.viewpager2.adapter.FragmentStateAdapter +import com.google.android.material.tabs.TabLayoutMediator import de.jrpie.android.launcher.R -import de.jrpie.android.launcher.REQUEST_CHOOSE_APP import de.jrpie.android.launcher.databinding.SettingsBinding import de.jrpie.android.launcher.preferences.LauncherPreferences import de.jrpie.android.launcher.preferences.theme.Background import de.jrpie.android.launcher.preferences.theme.ColorTheme -import de.jrpie.android.launcher.saveListActivityChoice import de.jrpie.android.launcher.ui.UIObject import de.jrpie.android.launcher.ui.settings.actions.SettingsFragmentActions import de.jrpie.android.launcher.ui.settings.launcher.SettingsFragmentLauncher @@ -49,15 +45,15 @@ class SettingsActivity : AppCompatActivity(), UIObject { // This ugly workaround causes a jump to the top of the list, but at least // the text stays readable. val i = Intent(this, SettingsActivity::class.java) - .also { it.putExtra("tab", 1) } + .also { it.putExtra(EXTRA_TAB, 1) } finish() startActivity(i) } else - if (prefKey?.startsWith("theme.") == true || - prefKey?.startsWith("display.") == true - ) { - recreate() - } + if (prefKey?.startsWith("theme.") == true || + prefKey?.startsWith("display.") == true + ) { + recreate() + } } private lateinit var binding: SettingsBinding @@ -71,15 +67,14 @@ class SettingsActivity : AppCompatActivity(), UIObject { setContentView(binding.root) // set up tabs and swiping in settings - val sectionsPagerAdapter = SettingsSectionsPagerAdapter(this, supportFragmentManager) - val viewPager: ViewPager = findViewById(R.id.settings_viewpager) - viewPager.adapter = sectionsPagerAdapter - - val tabs: TabLayout = findViewById(R.id.settings_tabs) - tabs.setupWithViewPager(viewPager) - if (intent.hasExtra("tab")) { - tabs.getTabAt(intent.getIntExtra("tab", 0))?.select() + val sectionsPagerAdapter = SettingsSectionsPagerAdapter(this) + binding.settingsViewpager.apply { + adapter = sectionsPagerAdapter + setCurrentItem(intent.getIntExtra(EXTRA_TAB, 0), false) } + TabLayoutMediator(binding.settingsTabs, binding.settingsViewpager) { tab, position -> + tab.text = sectionsPagerAdapter.getPageTitle(position) + }.attach() } override fun onStart() { @@ -108,24 +103,21 @@ class SettingsActivity : AppCompatActivity(), UIObject { } } - override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { - when (requestCode) { - REQUEST_CHOOSE_APP -> saveListActivityChoice(data) - else -> super.onActivityResult(requestCode, resultCode, data) - } + companion object { + private const val EXTRA_TAB = "tab" } } private val TAB_TITLES = arrayOf( - R.string.settings_tab_app, + R.string.settings_tab_actions, R.string.settings_tab_launcher, R.string.settings_tab_meta ) -class SettingsSectionsPagerAdapter(private val context: Context, fm: FragmentManager) : - FragmentPagerAdapter(fm, BEHAVIOR_RESUME_ONLY_CURRENT_FRAGMENT) { +class SettingsSectionsPagerAdapter(private val activity: FragmentActivity) : + FragmentStateAdapter(activity) { - override fun getItem(position: Int): Fragment { + override fun createFragment(position: Int): Fragment { return when (position) { 0 -> SettingsFragmentActions() 1 -> SettingsFragmentLauncher() @@ -134,11 +126,11 @@ class SettingsSectionsPagerAdapter(private val context: Context, fm: FragmentMan } } - override fun getPageTitle(position: Int): CharSequence { - return context.resources.getString(TAB_TITLES[position]) + fun getPageTitle(position: Int): CharSequence { + return activity.resources.getString(TAB_TITLES[position]) } - override fun getCount(): Int { + override fun getItemCount(): Int { return 3 } } diff --git a/app/src/main/java/de/jrpie/android/launcher/ui/settings/actions/SettingsFragmentActionsRecycler.kt b/app/src/main/java/de/jrpie/android/launcher/ui/settings/actions/SettingsFragmentActionsRecycler.kt index d7862fa..ae47ce2 100644 --- a/app/src/main/java/de/jrpie/android/launcher/ui/settings/actions/SettingsFragmentActionsRecycler.kt +++ b/app/src/main/java/de/jrpie/android/launcher/ui/settings/actions/SettingsFragmentActionsRecycler.kt @@ -11,11 +11,11 @@ import android.view.ViewGroup import android.widget.Button import android.widget.ImageView import android.widget.TextView +import androidx.appcompat.content.res.AppCompatResources import androidx.fragment.app.Fragment import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView import de.jrpie.android.launcher.R -import de.jrpie.android.launcher.REQUEST_CHOOSE_APP import de.jrpie.android.launcher.actions.Action import de.jrpie.android.launcher.actions.Gesture import de.jrpie.android.launcher.apps.AppFilter @@ -94,6 +94,8 @@ class SettingsFragmentActionsRecycler : Fragment(), UIObject { class ActionsRecyclerAdapter(val activity: Activity) : RecyclerView.Adapter() { + private val drawableUnknown = AppCompatResources.getDrawable(activity, R.drawable.baseline_question_mark_24) + private val gesturesList: ArrayList = Gesture.entries.filter(Gesture::isEnabled) as ArrayList @@ -115,15 +117,18 @@ class ActionsRecyclerAdapter(val activity: Activity) : private fun updateViewHolder(gesture: Gesture, viewHolder: ViewHolder) { val action = Action.forGesture(gesture) - val drawable = action?.getIcon(activity) - if (action == null || drawable == null) { + if (action == null) { viewHolder.img.visibility = View.INVISIBLE viewHolder.removeAction.visibility = View.GONE viewHolder.chooseButton.visibility = View.VISIBLE return } + // Use the unknown icon if there is an action, but we can't find its icon. + // Probably an app was uninstalled. + val drawable = action.getIcon(activity) ?: drawableUnknown + viewHolder.img.visibility = View.VISIBLE viewHolder.removeAction.visibility = View.VISIBLE viewHolder.chooseButton.visibility = View.INVISIBLE @@ -137,9 +142,7 @@ class ActionsRecyclerAdapter(val activity: Activity) : val description = gesture.getDescription(activity) viewHolder.descriptionTextView.text = description - - if (LauncherPreferences.theme().monochromeIcons()) - viewHolder.img.transformGrayscale() + viewHolder.img.transformGrayscale(LauncherPreferences.theme().monochromeIcons()) updateViewHolder(gesture, viewHolder) viewHolder.img.setOnClickListener { chooseApp(gesture) } @@ -175,9 +178,6 @@ class ActionsRecyclerAdapter(val activity: Activity) : intent.putExtra("intention", ListActivity.ListActivityIntention.PICK.toString()) intent.putExtra("hiddenVisibility", AppFilter.Companion.AppSetVisibility.VISIBLE) intent.putExtra("forGesture", gesture.id) // for which action we choose the app - activity.startActivityForResult( - intent, - REQUEST_CHOOSE_APP - ) + activity.startActivity(intent) } } diff --git a/app/src/main/java/de/jrpie/android/launcher/ui/settings/launcher/SettingsFragmentLauncher.kt b/app/src/main/java/de/jrpie/android/launcher/ui/settings/launcher/SettingsFragmentLauncher.kt index a8efb43..bb9df74 100644 --- a/app/src/main/java/de/jrpie/android/launcher/ui/settings/launcher/SettingsFragmentLauncher.kt +++ b/app/src/main/java/de/jrpie/android/launcher/ui/settings/launcher/SettingsFragmentLauncher.kt @@ -11,6 +11,8 @@ import de.jrpie.android.launcher.actions.openAppsList import de.jrpie.android.launcher.preferences.LauncherPreferences import de.jrpie.android.launcher.preferences.theme.ColorTheme import de.jrpie.android.launcher.setDefaultHomeScreen +import de.jrpie.android.launcher.ui.widgets.manage.ManageWidgetPanelsActivity +import de.jrpie.android.launcher.ui.widgets.manage.ManageWidgetsActivity /** @@ -81,6 +83,22 @@ class SettingsFragmentLauncher : PreferenceFragmentCompat() { true } + val manageWidgets = findPreference( + LauncherPreferences.widgets().keys().widgets() + ) + manageWidgets?.setOnPreferenceClickListener { + startActivity(Intent(requireActivity(), ManageWidgetsActivity::class.java)) + true + } + + val manageWidgetPanels = findPreference( + LauncherPreferences.widgets().keys().customPanels() + ) + manageWidgetPanels?.setOnPreferenceClickListener { + startActivity(Intent(requireActivity(), ManageWidgetPanelsActivity::class.java)) + true + } + val hiddenApps = findPreference( LauncherPreferences.apps().keys().hidden() ) diff --git a/app/src/main/java/de/jrpie/android/launcher/ui/settings/meta/SettingsFragmentMeta.kt b/app/src/main/java/de/jrpie/android/launcher/ui/settings/meta/SettingsFragmentMeta.kt index 70a225d..dea0bcf 100644 --- a/app/src/main/java/de/jrpie/android/launcher/ui/settings/meta/SettingsFragmentMeta.kt +++ b/app/src/main/java/de/jrpie/android/launcher/ui/settings/meta/SettingsFragmentMeta.kt @@ -2,7 +2,6 @@ package de.jrpie.android.launcher.ui.settings.meta import android.app.AlertDialog import android.content.Intent -import android.net.Uri import android.os.Bundle import android.view.LayoutInflater import android.view.View @@ -16,10 +15,10 @@ import de.jrpie.android.launcher.copyToClipboard import de.jrpie.android.launcher.databinding.SettingsMetaBinding import de.jrpie.android.launcher.getDeviceInfo import de.jrpie.android.launcher.openInBrowser +import de.jrpie.android.launcher.openTutorial import de.jrpie.android.launcher.preferences.resetPreferences import de.jrpie.android.launcher.ui.LegalInfoActivity import de.jrpie.android.launcher.ui.UIObject -import de.jrpie.android.launcher.ui.tutorial.TutorialActivity /** * The [SettingsFragmentMeta] is a used as a tab in the SettingsActivity. @@ -47,8 +46,17 @@ class SettingsFragmentMeta : Fragment(), UIObject { override fun setOnClicks() { + fun bindURL(view: View, urlRes: Int) { + view.setOnClickListener { + openInBrowser( + getString(urlRes), + requireContext() + ) + } + } + binding.settingsMetaButtonViewTutorial.setOnClickListener { - startActivity(Intent(this.context, TutorialActivity::class.java)) + openTutorial(requireContext()) } // prompting for settings-reset confirmation @@ -69,12 +77,7 @@ class SettingsFragmentMeta : Fragment(), UIObject { // view code - binding.settingsMetaButtonViewCode.setOnClickListener { - openInBrowser( - getString(R.string.settings_meta_link_github), - requireContext() - ) - } + bindURL(binding.settingsMetaButtonViewCode, R.string.settings_meta_link_github) // report a bug binding.settingsMetaButtonReportBug.setOnClickListener { @@ -110,37 +113,19 @@ class SettingsFragmentMeta : Fragment(), UIObject { } // join chat - binding.settingsMetaButtonJoinChat.setOnClickListener { - openInBrowser( - getString(R.string.settings_meta_chat_url), - requireContext() - ) - } - + bindURL(binding.settingsMetaButtonJoinChat, R.string.settings_meta_chat_url) // contact developer - binding.settingsMetaButtonContact.setOnClickListener { - openInBrowser( - getString(R.string.settings_meta_contact_url), - requireContext() - ) - } + // bindURL(binding.settingsMetaButtonContact, R.string.settings_meta_contact_url) // contact fork developer - binding.settingsMetaButtonForkContact.setOnClickListener { - openInBrowser( - getString(R.string.settings_meta_fork_contact_url), - requireContext() - ) - } + bindURL(binding.settingsMetaButtonForkContact, R.string.settings_meta_fork_contact_url) + + // donate + bindURL(binding.settingsMetaButtonDonate, R.string.settings_meta_donate_url) // privacy policy - binding.settingsMetaButtonPrivacy.setOnClickListener { - openInBrowser( - getString(R.string.settings_meta_privacy_url), - requireContext() - ) - } + bindURL(binding.settingsMetaButtonPrivacy, R.string.settings_meta_privacy_url) // legal info binding.settingsMetaButtonLicenses.setOnClickListener { diff --git a/app/src/main/java/de/jrpie/android/launcher/ui/tutorial/TutorialActivity.kt b/app/src/main/java/de/jrpie/android/launcher/ui/tutorial/TutorialActivity.kt index 28e2e02..847639c 100644 --- a/app/src/main/java/de/jrpie/android/launcher/ui/tutorial/TutorialActivity.kt +++ b/app/src/main/java/de/jrpie/android/launcher/ui/tutorial/TutorialActivity.kt @@ -1,24 +1,26 @@ package de.jrpie.android.launcher.ui.tutorial -import android.content.Intent import android.content.res.Resources +import android.os.Build import android.os.Bundle +import android.view.View +import android.window.OnBackInvokedDispatcher import androidx.appcompat.app.AppCompatActivity import androidx.fragment.app.Fragment -import androidx.fragment.app.FragmentManager -import androidx.fragment.app.FragmentPagerAdapter -import androidx.viewpager.widget.ViewPager -import com.google.android.material.tabs.TabLayout -import de.jrpie.android.launcher.R -import de.jrpie.android.launcher.REQUEST_CHOOSE_APP +import androidx.fragment.app.FragmentActivity +import androidx.viewpager2.adapter.FragmentStateAdapter +import androidx.viewpager2.widget.ViewPager2 +import com.google.android.material.tabs.TabLayoutMediator +import de.jrpie.android.launcher.databinding.TutorialBinding import de.jrpie.android.launcher.preferences.LauncherPreferences -import de.jrpie.android.launcher.saveListActivityChoice import de.jrpie.android.launcher.ui.UIObject -import de.jrpie.android.launcher.ui.tutorial.tabs.TutorialFragmentConcept -import de.jrpie.android.launcher.ui.tutorial.tabs.TutorialFragmentFinish -import de.jrpie.android.launcher.ui.tutorial.tabs.TutorialFragmentSetup -import de.jrpie.android.launcher.ui.tutorial.tabs.TutorialFragmentStart -import de.jrpie.android.launcher.ui.tutorial.tabs.TutorialFragmentUsage +import de.jrpie.android.launcher.ui.blink +import de.jrpie.android.launcher.ui.tutorial.tabs.TutorialFragment0Start +import de.jrpie.android.launcher.ui.tutorial.tabs.TutorialFragment1Concept +import de.jrpie.android.launcher.ui.tutorial.tabs.TutorialFragment2Usage +import de.jrpie.android.launcher.ui.tutorial.tabs.TutorialFragment3AppList +import de.jrpie.android.launcher.ui.tutorial.tabs.TutorialFragment4Setup +import de.jrpie.android.launcher.ui.tutorial.tabs.TutorialFragment5Finish /** * The [TutorialActivity] is displayed automatically on new installations. @@ -29,19 +31,75 @@ import de.jrpie.android.launcher.ui.tutorial.tabs.TutorialFragmentUsage */ class TutorialActivity : AppCompatActivity(), UIObject { + private lateinit var binding: TutorialBinding + + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) super.onCreate() // Initialise layout - setContentView(R.layout.tutorial) + binding = TutorialBinding.inflate(layoutInflater) + setContentView(binding.root) + + // Handle back key / gesture on Android 13+ + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + onBackInvokedDispatcher.registerOnBackInvokedCallback( + OnBackInvokedDispatcher.PRIORITY_OVERLAY + ) { + // prevent going back when the tutorial is shown for the first time + if (!LauncherPreferences.internal().started()) { + return@registerOnBackInvokedCallback + } + finish() + } + } + // set up tabs and swiping in settings - val sectionsPagerAdapter = TutorialSectionsPagerAdapter(supportFragmentManager) - val viewPager: ViewPager = findViewById(R.id.tutorial_viewpager) - viewPager.adapter = sectionsPagerAdapter - val tabs: TabLayout = findViewById(R.id.tutorial_tabs) - tabs.setupWithViewPager(viewPager) + val sectionsPagerAdapter = TutorialSectionsPagerAdapter(this) + binding.tutorialViewpager.apply { + adapter = sectionsPagerAdapter + currentItem = 0 + registerOnPageChangeCallback(object : ViewPager2.OnPageChangeCallback() { + override fun onPageSelected(position: Int) { + binding.tutorialButtonNext.apply { + val lastItem = sectionsPagerAdapter.itemCount - 1 + visibility = if (position == lastItem) { + View.INVISIBLE + } else { + View.VISIBLE + } + if (position == 0) { + blink() + } else { + clearAnimation() + } + } + binding.tutorialButtonBack.apply { + visibility = if (position == 0) { + View.INVISIBLE + } else { + View.VISIBLE + } + } + } + }) + } + TabLayoutMediator(binding.tutorialTabs, binding.tutorialViewpager) { _, _ -> }.attach() + binding.tutorialButtonNext.setOnClickListener { + binding.tutorialViewpager.apply { + setCurrentItem( + (currentItem + 1).coerceAtMost(sectionsPagerAdapter.itemCount - 1), + true + ) + } + } + binding.tutorialButtonBack.setOnClickListener { + binding.tutorialViewpager.apply { + setCurrentItem((currentItem - 1).coerceAtLeast(0), true) + } + } } override fun getTheme(): Resources.Theme { @@ -53,14 +111,9 @@ class TutorialActivity : AppCompatActivity(), UIObject { super.onStart() } - override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { - when (requestCode) { - REQUEST_CHOOSE_APP -> saveListActivityChoice(data) - else -> super.onActivityResult(requestCode, resultCode, data) - } - } - - // Default: prevent going back, allow if viewed again later + // prevent going back when the tutorial is shown for the first time + @Deprecated("Deprecated in Java", ReplaceWith("use anyway")) + @Suppress("deprecation") // support API level < 33 override fun onBackPressed() { if (LauncherPreferences.internal().started()) super.onBackPressed() @@ -74,26 +127,22 @@ class TutorialActivity : AppCompatActivity(), UIObject { * * Tabs: (Start | Concept | Usage | Setup | Finish) */ -class TutorialSectionsPagerAdapter(fm: FragmentManager) : - FragmentPagerAdapter(fm, BEHAVIOR_RESUME_ONLY_CURRENT_FRAGMENT) { +class TutorialSectionsPagerAdapter(activity: FragmentActivity) : + FragmentStateAdapter(activity) { - override fun getItem(position: Int): Fragment { + override fun getItemCount(): Int { + return 6 + } + + override fun createFragment(position: Int): Fragment { return when (position) { - 0 -> TutorialFragmentStart() - 1 -> TutorialFragmentConcept() - 2 -> TutorialFragmentUsage() - 3 -> TutorialFragmentSetup() - 4 -> TutorialFragmentFinish() + 0 -> TutorialFragment0Start() + 1 -> TutorialFragment1Concept() + 2 -> TutorialFragment2Usage() + 3 -> TutorialFragment3AppList() + 4 -> TutorialFragment4Setup() + 5 -> TutorialFragment5Finish() else -> Fragment() } } - - /* We don't use titles here, as we have the dots */ - override fun getPageTitle(position: Int): CharSequence { - return "" - } - - override fun getCount(): Int { - return 5 - } } diff --git a/app/src/main/java/de/jrpie/android/launcher/ui/tutorial/tabs/TutorialFragmentStart.kt b/app/src/main/java/de/jrpie/android/launcher/ui/tutorial/tabs/TutorialFragment0Start.kt similarity index 59% rename from app/src/main/java/de/jrpie/android/launcher/ui/tutorial/tabs/TutorialFragmentStart.kt rename to app/src/main/java/de/jrpie/android/launcher/ui/tutorial/tabs/TutorialFragment0Start.kt index 445ded1..5ce5920 100644 --- a/app/src/main/java/de/jrpie/android/launcher/ui/tutorial/tabs/TutorialFragmentStart.kt +++ b/app/src/main/java/de/jrpie/android/launcher/ui/tutorial/tabs/TutorialFragment0Start.kt @@ -5,24 +5,22 @@ import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import androidx.fragment.app.Fragment -import de.jrpie.android.launcher.databinding.TutorialStartBinding +import de.jrpie.android.launcher.databinding.Tutorial0StartBinding import de.jrpie.android.launcher.ui.UIObject -import de.jrpie.android.launcher.ui.blink /** - * The [TutorialFragmentStart] is a used as a tab in the TutorialActivity. + * The [TutorialFragment0Start] is a used as a tab in the TutorialActivity. * * It displays info about the app and gets the user into the tutorial */ -class TutorialFragmentStart : Fragment(), UIObject { +class TutorialFragment0Start : Fragment(), UIObject { - private lateinit var binding: TutorialStartBinding + private lateinit var binding: Tutorial0StartBinding override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? ): View { - binding = TutorialStartBinding.inflate(inflater, container, false) - binding.tutorialStartIconRight.blink() + binding = Tutorial0StartBinding.inflate(inflater, container, false) return binding.root } diff --git a/app/src/main/java/de/jrpie/android/launcher/ui/tutorial/tabs/TutorialFragmentConcept.kt b/app/src/main/java/de/jrpie/android/launcher/ui/tutorial/tabs/TutorialFragment1Concept.kt similarity index 68% rename from app/src/main/java/de/jrpie/android/launcher/ui/tutorial/tabs/TutorialFragmentConcept.kt rename to app/src/main/java/de/jrpie/android/launcher/ui/tutorial/tabs/TutorialFragment1Concept.kt index f0fd233..876266e 100644 --- a/app/src/main/java/de/jrpie/android/launcher/ui/tutorial/tabs/TutorialFragmentConcept.kt +++ b/app/src/main/java/de/jrpie/android/launcher/ui/tutorial/tabs/TutorialFragment1Concept.kt @@ -6,22 +6,22 @@ import android.view.View import android.view.ViewGroup import androidx.fragment.app.Fragment import de.jrpie.android.launcher.BuildConfig -import de.jrpie.android.launcher.databinding.TutorialConceptBinding +import de.jrpie.android.launcher.databinding.Tutorial1ConceptBinding import de.jrpie.android.launcher.ui.UIObject /** - * The [TutorialFragmentConcept] is a used as a tab in the TutorialActivity. + * The [TutorialFragment1Concept] is a used as a tab in the TutorialActivity. * * It is used to display info about Launchers concept (open source, efficiency ...) */ -class TutorialFragmentConcept : Fragment(), UIObject { - private lateinit var binding: TutorialConceptBinding +class TutorialFragment1Concept : Fragment(), UIObject { + private lateinit var binding: Tutorial1ConceptBinding override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? ): View { - binding = TutorialConceptBinding.inflate(inflater, container, false) + binding = Tutorial1ConceptBinding.inflate(inflater, container, false) binding.tutorialConceptBadgeVersion.text = BuildConfig.VERSION_NAME return binding.root } diff --git a/app/src/main/java/de/jrpie/android/launcher/ui/tutorial/tabs/TutorialFragmentUsage.kt b/app/src/main/java/de/jrpie/android/launcher/ui/tutorial/tabs/TutorialFragment2Usage.kt similarity index 75% rename from app/src/main/java/de/jrpie/android/launcher/ui/tutorial/tabs/TutorialFragmentUsage.kt rename to app/src/main/java/de/jrpie/android/launcher/ui/tutorial/tabs/TutorialFragment2Usage.kt index 90db232..4b24dcd 100644 --- a/app/src/main/java/de/jrpie/android/launcher/ui/tutorial/tabs/TutorialFragmentUsage.kt +++ b/app/src/main/java/de/jrpie/android/launcher/ui/tutorial/tabs/TutorialFragment2Usage.kt @@ -9,17 +9,17 @@ import de.jrpie.android.launcher.R import de.jrpie.android.launcher.ui.UIObject /** - * The [TutorialFragmentUsage] is a used as a tab in the TutorialActivity. + * The [TutorialFragment2Usage] is a used as a tab in the TutorialActivity. * * Tells the user how his screen will look and how the app can be used */ -class TutorialFragmentUsage : Fragment(), UIObject { +class TutorialFragment2Usage : Fragment(), UIObject { override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? ): View? { - return inflater.inflate(R.layout.tutorial_usage, container, false) + return inflater.inflate(R.layout.tutorial_2_usage, container, false) } override fun onStart() { diff --git a/app/src/main/java/de/jrpie/android/launcher/ui/tutorial/tabs/TutorialFragment3AppList.kt b/app/src/main/java/de/jrpie/android/launcher/ui/tutorial/tabs/TutorialFragment3AppList.kt new file mode 100644 index 0000000..78698aa --- /dev/null +++ b/app/src/main/java/de/jrpie/android/launcher/ui/tutorial/tabs/TutorialFragment3AppList.kt @@ -0,0 +1,30 @@ +package de.jrpie.android.launcher.ui.tutorial.tabs + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.fragment.app.Fragment +import de.jrpie.android.launcher.R +import de.jrpie.android.launcher.ui.UIObject + +/** + * The [TutorialFragment3AppList] is a used as a tab in the TutorialActivity. + * + * Tells the user how his screen will look and how the app can be used + */ +class TutorialFragment3AppList : Fragment(), UIObject { + + override fun onCreateView( + inflater: LayoutInflater, container: ViewGroup?, + savedInstanceState: Bundle? + ): View? { + return inflater.inflate(R.layout.tutorial_3_app_list, container, false) + } + + override fun onStart() { + super.onStart() + super.onStart() + } + +} diff --git a/app/src/main/java/de/jrpie/android/launcher/ui/tutorial/tabs/TutorialFragmentSetup.kt b/app/src/main/java/de/jrpie/android/launcher/ui/tutorial/tabs/TutorialFragment4Setup.kt similarity index 74% rename from app/src/main/java/de/jrpie/android/launcher/ui/tutorial/tabs/TutorialFragmentSetup.kt rename to app/src/main/java/de/jrpie/android/launcher/ui/tutorial/tabs/TutorialFragment4Setup.kt index 09ef4c9..56eb6ca 100644 --- a/app/src/main/java/de/jrpie/android/launcher/ui/tutorial/tabs/TutorialFragmentSetup.kt +++ b/app/src/main/java/de/jrpie/android/launcher/ui/tutorial/tabs/TutorialFragment4Setup.kt @@ -9,17 +9,17 @@ import de.jrpie.android.launcher.R import de.jrpie.android.launcher.ui.UIObject /** - * The [TutorialFragmentSetup] is a used as a tab in the TutorialActivity. + * The [TutorialFragment4Setup] is a used as a tab in the TutorialActivity. * * It is used to display info in the tutorial */ -class TutorialFragmentSetup : Fragment(), UIObject { +class TutorialFragment4Setup : Fragment(), UIObject { override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? ): View? { - return inflater.inflate(R.layout.tutorial_setup, container, false) + return inflater.inflate(R.layout.tutorial_4_setup, container, false) } override fun onStart() { diff --git a/app/src/main/java/de/jrpie/android/launcher/ui/tutorial/tabs/TutorialFragmentFinish.kt b/app/src/main/java/de/jrpie/android/launcher/ui/tutorial/tabs/TutorialFragment5Finish.kt similarity index 80% rename from app/src/main/java/de/jrpie/android/launcher/ui/tutorial/tabs/TutorialFragmentFinish.kt rename to app/src/main/java/de/jrpie/android/launcher/ui/tutorial/tabs/TutorialFragment5Finish.kt index 2d01d0a..2fd093e 100644 --- a/app/src/main/java/de/jrpie/android/launcher/ui/tutorial/tabs/TutorialFragmentFinish.kt +++ b/app/src/main/java/de/jrpie/android/launcher/ui/tutorial/tabs/TutorialFragment5Finish.kt @@ -6,25 +6,25 @@ import android.view.View import android.view.ViewGroup import androidx.fragment.app.Fragment import de.jrpie.android.launcher.BuildConfig.VERSION_CODE -import de.jrpie.android.launcher.databinding.TutorialFinishBinding +import de.jrpie.android.launcher.databinding.Tutorial5FinishBinding import de.jrpie.android.launcher.preferences.LauncherPreferences import de.jrpie.android.launcher.setDefaultHomeScreen import de.jrpie.android.launcher.ui.UIObject /** - * The [TutorialFragmentFinish] is a used as a tab in the TutorialActivity. + * The [TutorialFragment5Finish] is a used as a tab in the TutorialActivity. * * It is used to display further resources and let the user start Launcher */ -class TutorialFragmentFinish : Fragment(), UIObject { +class TutorialFragment5Finish : Fragment(), UIObject { - private lateinit var binding: TutorialFinishBinding + private lateinit var binding: Tutorial5FinishBinding override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? ): View { - binding = TutorialFinishBinding.inflate(inflater, container, false) + binding = Tutorial5FinishBinding.inflate(inflater, container, false) return binding.root } diff --git a/app/src/main/java/de/jrpie/android/launcher/ui/util/HtmlTextView.kt b/app/src/main/java/de/jrpie/android/launcher/ui/util/HtmlTextView.kt index 549f10f..5e38b9f 100644 --- a/app/src/main/java/de/jrpie/android/launcher/ui/util/HtmlTextView.kt +++ b/app/src/main/java/de/jrpie/android/launcher/ui/util/HtmlTextView.kt @@ -12,6 +12,7 @@ class HtmlTextView(context: Context, attr: AttributeSet?, int: Int) : constructor(context: Context) : this(context, null, 0) init { + @Suppress("deprecation") // required to support API level < 24 text = Html.fromHtml(text.toString()) movementMethod = LinkMovementMethod.getInstance() } diff --git a/app/src/main/java/de/jrpie/android/launcher/ui/widgets/ClockView.kt b/app/src/main/java/de/jrpie/android/launcher/ui/widgets/ClockView.kt new file mode 100644 index 0000000..33c4888 --- /dev/null +++ b/app/src/main/java/de/jrpie/android/launcher/ui/widgets/ClockView.kt @@ -0,0 +1,80 @@ +package de.jrpie.android.launcher.ui.widgets + +import android.content.Context +import android.util.AttributeSet +import android.view.LayoutInflater +import androidx.constraintlayout.widget.ConstraintLayout +import androidx.core.view.isVisible +import de.jrpie.android.launcher.actions.Gesture +import de.jrpie.android.launcher.databinding.ClockBinding +import de.jrpie.android.launcher.preferences.LauncherPreferences +import java.util.Locale + +class ClockView(context: Context, attrs: AttributeSet? = null, val appWidgetId: Int): ConstraintLayout(context, attrs) { + + val binding: ClockBinding = ClockBinding.inflate(LayoutInflater.from(context), this, true) + init { + initClock() + setOnClicks() + } + + + private fun initClock() { + val locale = Locale.getDefault() + val dateVisible = LauncherPreferences.clock().dateVisible() + val timeVisible = LauncherPreferences.clock().timeVisible() + + var dateFMT = "yyyy-MM-dd" + var timeFMT = "HH:mm" + if (LauncherPreferences.clock().showSeconds()) { + timeFMT += ":ss" + } + + if (LauncherPreferences.clock().localized()) { + dateFMT = android.text.format.DateFormat.getBestDateTimePattern(locale, dateFMT) + timeFMT = android.text.format.DateFormat.getBestDateTimePattern(locale, timeFMT) + } + + var upperFormat = dateFMT + var lowerFormat = timeFMT + var upperVisible = dateVisible + var lowerVisible = timeVisible + + if (LauncherPreferences.clock().flipDateTime()) { + upperFormat = lowerFormat.also { lowerFormat = upperFormat } + upperVisible = lowerVisible.also { lowerVisible = upperVisible } + } + + binding.clockUpperView.isVisible = upperVisible + binding.clockLowerView.isVisible = lowerVisible + + binding.clockUpperView.setTextColor(LauncherPreferences.clock().color()) + binding.clockLowerView.setTextColor(LauncherPreferences.clock().color()) + + binding.clockLowerView.format24Hour = lowerFormat + binding.clockUpperView.format24Hour = upperFormat + binding.clockLowerView.format12Hour = lowerFormat + binding.clockUpperView.format12Hour = upperFormat + } + + fun setOnClicks() { + binding.clockUpperView.setOnClickListener { + if (LauncherPreferences.clock().flipDateTime()) { + Gesture.TIME(context) + } else { + Gesture.DATE(context) + } + } + + binding.clockLowerView.setOnClickListener { + if (LauncherPreferences.clock().flipDateTime()) { + Gesture.DATE(context) + } else { + Gesture.TIME(context) + } + } + } + + + +} \ No newline at end of file diff --git a/app/src/main/java/de/jrpie/android/launcher/ui/widgets/WidgetContainerView.kt b/app/src/main/java/de/jrpie/android/launcher/ui/widgets/WidgetContainerView.kt new file mode 100644 index 0000000..d071771 --- /dev/null +++ b/app/src/main/java/de/jrpie/android/launcher/ui/widgets/WidgetContainerView.kt @@ -0,0 +1,144 @@ +package de.jrpie.android.launcher.ui.widgets + +import android.app.Activity +import android.content.Context +import android.graphics.PointF +import android.graphics.RectF +import android.util.AttributeSet +import android.util.Log +import android.view.MotionEvent +import android.view.View +import android.view.View.MeasureSpec.makeMeasureSpec +import android.view.ViewGroup +import androidx.core.graphics.contains +import androidx.core.view.size +import de.jrpie.android.launcher.widgets.Widget +import de.jrpie.android.launcher.widgets.WidgetPanel +import de.jrpie.android.launcher.widgets.WidgetPosition +import kotlin.math.max + + +/** + * This only works in an Activity, not AppCompatActivity + */ +open class WidgetContainerView( + var widgetPanelId: Int, + context: Context, + attrs: AttributeSet? = null +) : ViewGroup(context, attrs) { + constructor(context: Context, attrs: AttributeSet) : this(WidgetPanel.HOME.id, context, attrs) + + var widgetViewById = HashMap() + + open fun updateWidgets(activity: Activity, widgets: Collection?) { + synchronized(widgetViewById) { + if (widgets == null) { + return + } + Log.i("WidgetContainer", "updating ${activity.localClassName}") + widgetViewById.forEach { removeView(it.value) } + widgetViewById.clear() + widgets.filter { it.panelId == widgetPanelId }.forEach { widget -> + widget.createView(activity)?.let { + addView(it, LayoutParams(widget.position)) + widgetViewById.put(widget.id, it) + } + } + } + } + + override fun onInterceptTouchEvent(ev: MotionEvent?): Boolean { + if (ev == null) { + return false + } + val position = PointF(ev.x, ev.y) + + return widgetViewById.filter { + RectF( + it.value.x, + it.value.y, + it.value.x + it.value.width, + it.value.y + it.value.height + ).contains(position) == true + }.any { + Widget.byId(context, it.key)?.allowInteraction == false + } + } + override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { + + var maxHeight = suggestedMinimumHeight + var maxWidth = suggestedMinimumWidth + + val mWidth = MeasureSpec.getSize(widthMeasureSpec) + val mHeight = MeasureSpec.getSize(heightMeasureSpec) + + (0...onCreate(savedInstanceState) + super.onCreate() + widgetPanelId = intent.getIntExtra(EXTRA_PANEL_ID, WidgetPanel.Companion.HOME.id) + val binding = ActivityWidgetPanelBinding.inflate(layoutInflater) + setContentView(binding.root) + binding.widgetPanelWidgetContainer.widgetPanelId = widgetPanelId + binding.widgetPanelWidgetContainer.updateWidgets( + this, + LauncherPreferences.widgets().widgets() + ) + } + + override fun getTheme(): Resources.Theme { + val mTheme = modifyTheme(super.getTheme()) + mTheme.applyStyle(R.style.backgroundWallpaper, true) + LauncherPreferences.clock().font().applyToTheme(mTheme) + LauncherPreferences.theme().colorTheme().applyToTheme( + mTheme, + LauncherPreferences.theme().textShadow() + ) + + return mTheme + } + + + override fun onStart() { + super.onStart() + super.onStart() + } + + override fun isHomeScreen(): Boolean { + return true + } +} \ No newline at end of file diff --git a/app/src/main/java/de/jrpie/android/launcher/ui/widgets/manage/ManageWidgetPanelsActivity.kt b/app/src/main/java/de/jrpie/android/launcher/ui/widgets/manage/ManageWidgetPanelsActivity.kt new file mode 100644 index 0000000..b18852f --- /dev/null +++ b/app/src/main/java/de/jrpie/android/launcher/ui/widgets/manage/ManageWidgetPanelsActivity.kt @@ -0,0 +1,104 @@ +package de.jrpie.android.launcher.ui.widgets.manage + +import android.annotation.SuppressLint +import android.content.Intent +import android.content.SharedPreferences +import android.content.res.Resources +import android.os.Bundle +import android.widget.EditText +import androidx.appcompat.app.AlertDialog +import androidx.appcompat.app.AppCompatActivity +import androidx.recyclerview.widget.LinearLayoutManager +import de.jrpie.android.launcher.R +import de.jrpie.android.launcher.databinding.ActivityManageWidgetPanelsBinding +import de.jrpie.android.launcher.preferences.LauncherPreferences +import de.jrpie.android.launcher.ui.UIObject +import de.jrpie.android.launcher.widgets.WidgetPanel +import de.jrpie.android.launcher.widgets.updateWidgetPanel + +class ManageWidgetPanelsActivity : AppCompatActivity(), UIObject { + + private val sharedPreferencesListener = + SharedPreferences.OnSharedPreferenceChangeListener { _, prefKey -> + if (prefKey == LauncherPreferences.widgets().keys().customPanels()) { + viewAdapter.widgetPanels = + (LauncherPreferences.widgets().customPanels() ?: setOf()).toTypedArray() + + @SuppressLint("NotifyDataSetChanged") + viewAdapter.notifyDataSetChanged() + } + } + private lateinit var binding: ActivityManageWidgetPanelsBinding + private lateinit var viewAdapter: WidgetPanelsRecyclerAdapter + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + super.onCreate() + + binding = ActivityManageWidgetPanelsBinding.inflate(layoutInflater) + setContentView(binding.main) + + val viewManager = LinearLayoutManager(this) + viewAdapter = WidgetPanelsRecyclerAdapter(this, true) { widgetPanel -> + startActivity( + Intent( + this@ManageWidgetPanelsActivity, + ManageWidgetsActivity::class.java + ).also { + it.putExtra(EXTRA_PANEL_ID, widgetPanel.id) + }) + } + binding.manageWidgetPanelsRecycler.apply { + // improve performance (since content changes don't change the layout size) + setHasFixedSize(true) + layoutManager = viewManager + adapter = viewAdapter + } + binding.manageWidgetPanelsClose.setOnClickListener { finish() } + binding.manageWidgetPanelsAddPanel.setOnClickListener { + AlertDialog.Builder(this@ManageWidgetPanelsActivity, R.style.AlertDialogCustom).apply { + setTitle(R.string.dialog_create_widget_panel_title) + setNegativeButton(R.string.dialog_cancel) { _, _ -> } + setPositiveButton(R.string.dialog_ok) { dialogInterface, _ -> + val panelId = WidgetPanel.allocateId() + val label = (dialogInterface as? AlertDialog) + ?.findViewById(R.id.dialog_create_widget_panel_edit_text)?.text?.toString() + ?: (getString(R.string.widget_panel_default_name, panelId)) + + updateWidgetPanel(WidgetPanel(panelId, label)) + } + setView(R.layout.dialog_create_widget_panel) + }.create().also { it.show() }.apply { + findViewById(R.id.dialog_create_widget_panel_edit_text) + ?.setText( + getString( + R.string.widget_panel_default_name, + WidgetPanel.allocateId() + ) + ) + } + true + } + } + + override fun onStart() { + super.onStart() + super.onStart() + LauncherPreferences.getSharedPreferences() + .registerOnSharedPreferenceChangeListener(sharedPreferencesListener) + } + + override fun onPause() { + LauncherPreferences.getSharedPreferences() + .unregisterOnSharedPreferenceChangeListener(sharedPreferencesListener) + super.onPause() + } + + override fun getTheme(): Resources.Theme { + return modifyTheme(super.getTheme()) + } + + override fun setOnClicks() { + binding.manageWidgetPanelsClose.setOnClickListener { finish() } + } +} \ No newline at end of file diff --git a/app/src/main/java/de/jrpie/android/launcher/ui/widgets/manage/ManageWidgetsActivity.kt b/app/src/main/java/de/jrpie/android/launcher/ui/widgets/manage/ManageWidgetsActivity.kt new file mode 100644 index 0000000..d191b70 --- /dev/null +++ b/app/src/main/java/de/jrpie/android/launcher/ui/widgets/manage/ManageWidgetsActivity.kt @@ -0,0 +1,185 @@ +package de.jrpie.android.launcher.ui.widgets.manage + +import android.app.Activity +import android.appwidget.AppWidgetManager +import android.content.Intent +import android.content.SharedPreferences +import android.content.res.Resources +import android.graphics.Rect +import android.os.Bundle +import android.util.Log +import androidx.core.view.ViewCompat +import androidx.core.view.WindowInsetsCompat +import com.google.android.material.floatingactionbutton.FloatingActionButton +import de.jrpie.android.launcher.Application +import de.jrpie.android.launcher.R +import de.jrpie.android.launcher.preferences.LauncherPreferences +import de.jrpie.android.launcher.ui.UIObject +import de.jrpie.android.launcher.ui.widgets.WidgetContainerView +import de.jrpie.android.launcher.widgets.AppWidget +import de.jrpie.android.launcher.widgets.WidgetPanel +import de.jrpie.android.launcher.widgets.WidgetPosition +import kotlin.math.min + + +// http://coderender.blogspot.com/2012/01/hosting-android-widgets-my.html + +const val REQUEST_CREATE_APPWIDGET = 1 +const val REQUEST_PICK_APPWIDGET = 2 + +const val EXTRA_PANEL_ID = "widgetPanelId" + +// We can't use AppCompatActivity, since some AppWidgets don't work there. +class ManageWidgetsActivity : Activity(), UIObject { + + var panelId: Int = WidgetPanel.HOME.id + + private var sharedPreferencesListener = + SharedPreferences.OnSharedPreferenceChangeListener { _, prefKey -> + if (prefKey == LauncherPreferences.widgets().keys().widgets()) { + // We can't observe the livedata because this is not an AppCompatActivity + findViewById(R.id.manage_widgets_container).updateWidgets(this, + LauncherPreferences.widgets().widgets() + ) + } + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + super.onCreate() + setContentView(R.layout.activity_manage_widgets) + + panelId = intent.extras?.getInt(EXTRA_PANEL_ID, WidgetPanel.HOME.id) ?: WidgetPanel.HOME.id + + findViewById(R.id.manage_widgets_button_add).setOnClickListener { + selectWidget() + } + + ViewCompat.setOnApplyWindowInsetsListener(findViewById(R.id.main)) { v, insets -> + val systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars()) + v.setPadding(systemBars.left, systemBars.top, systemBars.right, systemBars.bottom) + insets + } + + findViewById(R.id.manage_widgets_container).let { + it.widgetPanelId = panelId + it.updateWidgets(this, (application as Application).widgets.value) + } + } + + override fun onStart() { + super.onStart() + super.onStart() + + LauncherPreferences.getSharedPreferences() + .registerOnSharedPreferenceChangeListener(sharedPreferencesListener) + + } + + override fun onResume() { + super.onResume() + findViewById(R.id.manage_widgets_container).updateWidgets(this, + LauncherPreferences.widgets().widgets() + ) + + } + override fun getTheme(): Resources.Theme { + val mTheme = modifyTheme(super.getTheme()) + mTheme.applyStyle(R.style.backgroundWallpaper, true) + LauncherPreferences.clock().font().applyToTheme(mTheme) + LauncherPreferences.theme().colorTheme().applyToTheme( + mTheme, + LauncherPreferences.theme().textShadow() + ) + return mTheme + } + + override fun onDestroy() { + LauncherPreferences.getSharedPreferences() + .unregisterOnSharedPreferenceChangeListener(sharedPreferencesListener) + super.onDestroy() + } + + + fun selectWidget() { + val appWidgetHost = (application as Application).appWidgetHost + startActivityForResult( + Intent(this, SelectWidgetActivity::class.java).also { + it.putExtra( + AppWidgetManager.EXTRA_APPWIDGET_ID, + appWidgetHost.allocateAppWidgetId() + ) + it.putExtra( + EXTRA_PANEL_ID, + panelId + ) + }, REQUEST_PICK_APPWIDGET + ) + } + + + fun createWidget(data: Intent) { + Log.i("Launcher", "creating widget") + val appWidgetManager = (application as Application).appWidgetManager + val appWidgetId = data.extras?.getInt(AppWidgetManager.EXTRA_APPWIDGET_ID) ?: return + + val provider = appWidgetManager.getAppWidgetInfo(appWidgetId) + + val display = windowManager.defaultDisplay + + val position = WidgetPosition.fromAbsoluteRect( + Rect(0,0, + min(400, appWidgetManager.getAppWidgetInfo(appWidgetId).minWidth), + min(400, appWidgetManager.getAppWidgetInfo(appWidgetId).minHeight) + ), + display.width, + display.height + ) + + val widget = AppWidget(appWidgetId, position, panelId, provider) + LauncherPreferences.widgets().widgets( + (LauncherPreferences.widgets().widgets() ?: HashSet()).also { + it.add(widget) + } + ) + } + + private fun configureWidget(data: Intent) { + val extras = data.extras + val appWidgetId = extras!!.getInt(AppWidgetManager.EXTRA_APPWIDGET_ID, -1) + val widget = AppWidget(appWidgetId, panelId = panelId) + if (widget.isConfigurable(this)) { + widget.configure(this, REQUEST_CREATE_APPWIDGET) + } else { + createWidget(data) + } + } + + override fun onActivityResult( + requestCode: Int, resultCode: Int, + data: Intent? + ) { + super.onActivityResult(requestCode, resultCode, data) + if (resultCode == RESULT_OK) { + if (requestCode == REQUEST_PICK_APPWIDGET) { + configureWidget(data!!) + } else if (requestCode == REQUEST_CREATE_APPWIDGET) { + createWidget(data!!) + } + } else if (resultCode == RESULT_CANCELED && data != null) { + val appWidgetId = + data.getIntExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, -1) + if (appWidgetId != -1) { + AppWidget(appWidgetId).delete(this) + } + } + } + + + /** + * For a better preview, [ManageWidgetsActivity] should behave exactly like [HomeActivity] + */ + override fun isHomeScreen(): Boolean { + return true + } +} diff --git a/app/src/main/java/de/jrpie/android/launcher/ui/widgets/manage/SelectWidgetActivity.kt b/app/src/main/java/de/jrpie/android/launcher/ui/widgets/manage/SelectWidgetActivity.kt new file mode 100644 index 0000000..a1bd3b5 --- /dev/null +++ b/app/src/main/java/de/jrpie/android/launcher/ui/widgets/manage/SelectWidgetActivity.kt @@ -0,0 +1,172 @@ +package de.jrpie.android.launcher.ui.widgets.manage + +import android.appwidget.AppWidgetManager +import android.appwidget.AppWidgetProviderInfo +import android.content.Intent +import android.content.res.Resources +import android.os.Build +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.ImageView +import android.widget.TextView +import androidx.appcompat.app.AppCompatActivity +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import de.jrpie.android.launcher.R +import de.jrpie.android.launcher.databinding.ActivitySelectWidgetBinding +import de.jrpie.android.launcher.ui.UIObject +import de.jrpie.android.launcher.widgets.ClockWidget +import de.jrpie.android.launcher.widgets.LauncherAppWidgetProvider +import de.jrpie.android.launcher.widgets.LauncherClockWidgetProvider +import de.jrpie.android.launcher.widgets.LauncherWidgetProvider +import de.jrpie.android.launcher.widgets.WidgetPanel +import de.jrpie.android.launcher.widgets.WidgetPosition +import de.jrpie.android.launcher.widgets.bindAppWidgetOrRequestPermission +import de.jrpie.android.launcher.widgets.getAppWidgetHost +import de.jrpie.android.launcher.widgets.getAppWidgetProviders +import de.jrpie.android.launcher.widgets.updateWidget + + +private const val REQUEST_WIDGET_PERMISSION = 29 + +/** + * This activity lets the user pick an app widget to add. + * It provides an interface similar to [android.appwidget.AppWidgetManager.ACTION_APPWIDGET_PICK], + * but shows more information and also shows widgets from other user profiles. + */ +class SelectWidgetActivity : AppCompatActivity(), UIObject { + lateinit var binding: ActivitySelectWidgetBinding + var widgetId: Int = -1 + var widgetPanelId: Int = WidgetPanel.HOME.id + + private fun tryBindWidget(info: LauncherWidgetProvider) { + when (info) { + is LauncherAppWidgetProvider -> { + if (bindAppWidgetOrRequestPermission( + this, + info.info, + widgetId, + REQUEST_WIDGET_PERMISSION + ) + ) { + setResult( + RESULT_OK, + Intent().also { + it.putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, widgetId) + it.putExtra(EXTRA_PANEL_ID, widgetPanelId) + } + ) + finish() + } + } + is LauncherClockWidgetProvider -> { + updateWidget(ClockWidget(widgetId, WidgetPosition(0, 4, 12, 3), widgetPanelId)) + finish() + } + } + } + + override fun onStart() { + super.onStart() + super.onStart() + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + super.onCreate() + + binding = ActivitySelectWidgetBinding.inflate(layoutInflater) + setContentView(binding.root) + + + widgetId = intent.getIntExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, -1) + widgetPanelId = intent.getIntExtra(EXTRA_PANEL_ID, WidgetPanel.HOME.id) + if (widgetId == -1) { + widgetId = getAppWidgetHost().allocateAppWidgetId() + } + + val viewManager = LinearLayoutManager(this) + val viewAdapter = SelectWidgetRecyclerAdapter() + + binding.selectWidgetRecycler.apply { + setHasFixedSize(false) + layoutManager = viewManager + adapter = viewAdapter + } + } + + override fun getTheme(): Resources.Theme { + return modifyTheme(super.getTheme()) + } + + + override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { + super.onActivityResult(requestCode, resultCode, data) + + if (requestCode == REQUEST_WIDGET_PERMISSION && resultCode == RESULT_OK) { + data ?: return + val provider = (data.getSerializableExtra(AppWidgetManager.EXTRA_APPWIDGET_PROVIDER) as? AppWidgetProviderInfo) ?: return + tryBindWidget(LauncherAppWidgetProvider(provider)) + } + } + + inner class SelectWidgetRecyclerAdapter() : + RecyclerView.Adapter() { + + private val widgets = getAppWidgetProviders(this@SelectWidgetActivity).toTypedArray() + + inner class ViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView), + View.OnClickListener { + var textView: TextView = itemView.findViewById(R.id.list_widgets_row_name) + var descriptionView: TextView = itemView.findViewById(R.id.list_widgets_row_description) + var iconView: ImageView = itemView.findViewById(R.id.list_widgets_row_icon) + var previewView: ImageView = itemView.findViewById(R.id.list_widgets_row_preview) + + + override fun onClick(v: View) { + tryBindWidget(widgets[bindingAdapterPosition]) + } + + init { + itemView.setOnClickListener(this) + } + } + + override fun onBindViewHolder(viewHolder: ViewHolder, i: Int) { + val label = widgets[i].loadLabel(this@SelectWidgetActivity) + val description = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + widgets[i].loadDescription(this@SelectWidgetActivity) + } else { + "" + } + val preview = + widgets[i].loadPreviewImage(this@SelectWidgetActivity) + val icon = + widgets[i].loadIcon(this@SelectWidgetActivity) + + viewHolder.textView.text = label + viewHolder.descriptionView.text = description + viewHolder.descriptionView.visibility = + if (description?.isEmpty() == false) { View.VISIBLE } else { View.GONE } + viewHolder.iconView.setImageDrawable(icon) + + viewHolder.previewView.setImageDrawable(preview) + viewHolder.previewView.visibility = + if (preview != null) { View.VISIBLE } else { View.GONE } + + viewHolder.previewView.requestLayout() + } + + override fun getItemCount(): Int { + return widgets.size + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder { + val inflater = LayoutInflater.from(parent.context) + val view: View = inflater.inflate(R.layout.list_widgets_row, parent, false) + return ViewHolder(view) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/de/jrpie/android/launcher/ui/widgets/manage/WidgetManagerView.kt b/app/src/main/java/de/jrpie/android/launcher/ui/widgets/manage/WidgetManagerView.kt new file mode 100644 index 0000000..7a355f7 --- /dev/null +++ b/app/src/main/java/de/jrpie/android/launcher/ui/widgets/manage/WidgetManagerView.kt @@ -0,0 +1,175 @@ +package de.jrpie.android.launcher.ui.widgets.manage + +import android.annotation.SuppressLint +import android.app.Activity +import android.content.Context +import android.graphics.Point +import android.graphics.Rect +import android.graphics.RectF +import android.os.Build +import android.os.Handler +import android.os.Looper +import android.util.AttributeSet +import android.view.HapticFeedbackConstants +import android.view.MotionEvent +import android.view.View +import android.view.ViewConfiguration +import androidx.core.graphics.contains +import androidx.core.graphics.minus +import androidx.core.graphics.toRect +import androidx.core.view.children +import de.jrpie.android.launcher.ui.widgets.WidgetContainerView +import de.jrpie.android.launcher.widgets.Widget +import de.jrpie.android.launcher.widgets.WidgetPanel +import de.jrpie.android.launcher.widgets.WidgetPosition +import de.jrpie.android.launcher.widgets.updateWidget +import kotlin.math.max +import kotlin.math.min + +/** + * A variant of the [WidgetContainerView] which allows to manage widgets. + */ +class WidgetManagerView(widgetPanelId: Int, context: Context, attrs: AttributeSet? = null) : + WidgetContainerView(widgetPanelId, context, attrs) { + constructor(context: Context, attrs: AttributeSet?) : this(WidgetPanel.HOME.id, context, attrs) + + val TOUCH_SLOP: Int + val TOUCH_SLOP_SQUARE: Int + val LONG_PRESS_TIMEOUT: Long + + init { + val configuration = ViewConfiguration.get(context) + TOUCH_SLOP = configuration.scaledTouchSlop + TOUCH_SLOP_SQUARE = TOUCH_SLOP * TOUCH_SLOP + + LONG_PRESS_TIMEOUT = ViewConfiguration.getLongPressTimeout().toLong() + } + + + + enum class EditMode(val resize: (dx: Int, dy: Int, rect: Rect) -> Rect) { + MOVE({ dx, dy, rect -> + Rect(rect.left + dx, rect.top + dy, rect.right + dx, rect.bottom + dy) + }), + TOP({ dx, dy, rect -> + Rect(rect.left, min(rect.top + dy, rect.bottom - 200), rect.right, rect.bottom) + }), + BOTTOM({ dx, dy, rect -> + Rect(rect.left, rect.top, rect.right, max(rect.top + 200, rect.bottom + dy)) + }), + LEFT({ dx, dy, rect -> + Rect(min(rect.left + dx, rect.right - 200), rect.top, rect.right, rect.bottom) + }), + RIGHT({ dx, dy, rect -> + Rect(rect.left, rect.top, max(rect.left + 200, rect.right + dx), rect.bottom) + }), + } + + var selectedWidgetOverlayView: WidgetOverlayView? = null + var selectedWidgetView: View? = null + var currentGestureStart: Point? = null + var startWidgetPosition: Rect? = null + var lastPosition = Rect() + + private val longPressHandler = Handler(Looper.getMainLooper()) + + + override fun onInterceptTouchEvent(ev: MotionEvent?): Boolean { + return true + } + + @SuppressLint("ClickableViewAccessibility") + override fun onTouchEvent(event: MotionEvent?): Boolean { + if (event == null) { + return false + } + synchronized(this) { + if (event.actionMasked == MotionEvent.ACTION_DOWN) { + val start = Point(event.x.toInt(), event.y.toInt()) + currentGestureStart = start + val view = children.mapNotNull { it as? WidgetOverlayView }.firstOrNull { + RectF(it.x, it.y, it.x + it.width, it.y + it.height).toRect().contains(start) == true + } ?: return false + + val position = (view.layoutParams as Companion.LayoutParams).position.getAbsoluteRect(width, height) + selectedWidgetOverlayView = view + selectedWidgetView = widgetViewById.get(view.widgetId) ?: return true + startWidgetPosition = position + + val positionInView = start.minus(Point(position.left, position.top)) + view.mode = view.getHandles().firstOrNull { it.position.contains(positionInView) }?.mode ?: EditMode.MOVE + + longPressHandler.postDelayed({ + synchronized(this@WidgetManagerView) { + view.showPopupMenu() + view.performHapticFeedback(HapticFeedbackConstants.LONG_PRESS) + endInteraction() + } + }, LONG_PRESS_TIMEOUT) + } + if (event.actionMasked == MotionEvent.ACTION_MOVE || + event.actionMasked == MotionEvent.ACTION_UP + ) { + val distanceX = event.x - (currentGestureStart?.x ?: return true) + val distanceY = event.y - (currentGestureStart?.y ?: return true) + if (distanceX * distanceX + distanceY * distanceY > TOUCH_SLOP_SQUARE) { + longPressHandler.removeCallbacksAndMessages(null) + } + val view = selectedWidgetOverlayView ?: return true + val start = startWidgetPosition ?: return true + val absoluteNewPosition = view.mode?.resize( + distanceX.toInt(), + distanceY.toInt(), + start + ) ?: return true + val newPosition = WidgetPosition.fromAbsoluteRect( + absoluteNewPosition, width, height + ) + if (newPosition != lastPosition) { + lastPosition = absoluteNewPosition + (view.layoutParams as Companion.LayoutParams).position = newPosition + (selectedWidgetView?.layoutParams as? Companion.LayoutParams)?.position = newPosition + requestLayout() + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1) { + view.performHapticFeedback(HapticFeedbackConstants.KEYBOARD_PRESS) + } + } + + if (event.actionMasked == MotionEvent.ACTION_UP) { + longPressHandler.removeCallbacksAndMessages(null) + val id = selectedWidgetOverlayView?.widgetId ?: return true + val widget = Widget.byId(context, id) ?: return true + widget.position = newPosition + endInteraction() + updateWidget(widget) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + view.performHapticFeedback(HapticFeedbackConstants.GESTURE_END) + } + } + } + } + + + return true + } + private fun endInteraction() { + startWidgetPosition = null + selectedWidgetOverlayView?.mode = null + } + + override fun updateWidgets(activity: Activity, widgets: Collection?) { + super.updateWidgets(activity, widgets) + if (widgets == null) { + return + } + children.mapNotNull { it as? WidgetOverlayView }.forEach { removeView(it) } + + widgets.filter { it.panelId == widgetPanelId }.forEach { widget -> + WidgetOverlayView(activity).let { + addView(it) + it.widgetId = widget.id + (it.layoutParams as Companion.LayoutParams).position = widget.position + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/de/jrpie/android/launcher/ui/widgets/manage/WidgetOverlayView.kt b/app/src/main/java/de/jrpie/android/launcher/ui/widgets/manage/WidgetOverlayView.kt new file mode 100644 index 0000000..1b8a2d2 --- /dev/null +++ b/app/src/main/java/de/jrpie/android/launcher/ui/widgets/manage/WidgetOverlayView.kt @@ -0,0 +1,132 @@ +package de.jrpie.android.launcher.ui.widgets.manage + +import android.content.Context +import android.graphics.Canvas +import android.graphics.Paint +import android.graphics.Rect +import android.graphics.drawable.Drawable +import android.util.AttributeSet +import android.view.View +import android.widget.PopupMenu +import androidx.core.graphics.toRectF +import de.jrpie.android.launcher.R +import de.jrpie.android.launcher.widgets.Widget +import de.jrpie.android.launcher.widgets.updateWidget + + +private const val HANDLE_SIZE = 100 +private const val HANDLE_EDGE_SIZE = (1.2 * HANDLE_SIZE).toInt() + +/** + * An overlay to show configuration options for a widget in [WidgetManagerView] + */ +class WidgetOverlayView : View { + + + val paint = Paint() + val handlePaint = Paint() + val selectedHandlePaint = Paint() + var mode: WidgetManagerView.EditMode? = null + class Handle(val mode: WidgetManagerView.EditMode, val position: Rect) + init { + handlePaint.style = Paint.Style.STROKE + handlePaint.setARGB(255, 255, 255, 255) + + selectedHandlePaint.style = Paint.Style.FILL_AND_STROKE + selectedHandlePaint.setARGB(100, 255, 255, 255) + + paint.style = Paint.Style.STROKE + paint.setARGB(255, 255, 255, 255) + } + + private var preview: Drawable? = null + var widgetId: Int = -1 + set(newId) { + field = newId + preview = Widget.byId(context, widgetId)?.getPreview(context) + } + + constructor(context: Context) : super(context) { + init(null, 0) + } + + constructor(context: Context, attrs: AttributeSet) : super(context, attrs) { + init(attrs, 0) + } + + constructor(context: Context, attrs: AttributeSet, defStyle: Int) : super( + context, + attrs, + defStyle + ) { + init(attrs, defStyle) + } + + private fun init(attrs: AttributeSet?, defStyle: Int) { } + + override fun onDraw(canvas: Canvas) { + super.onDraw(canvas) + + getHandles().forEach { + if (it.mode == mode) { + canvas.drawRoundRect(it.position.toRectF(), 5f, 5f, selectedHandlePaint) + } else { + canvas.drawRoundRect(it.position.toRectF(), 5f, 5f, handlePaint) + } + } + val bounds = getBounds() + canvas.drawRoundRect(bounds.toRectF(), 5f, 5f, paint) + + if (mode == null) { + return + } + + //preview?.bounds = bounds + //preview?.draw(canvas) + + + } + + fun showPopupMenu() { + val widget = Widget.byId(context, widgetId)?: return + val menu = PopupMenu(context, this) + menu.menu.let { + it.add( + context.getString(R.string.widget_menu_remove) + ).setOnMenuItemClickListener { _ -> + Widget.byId(context, widgetId)?.delete(context) + return@setOnMenuItemClickListener true + } + it.add( + if (widget.allowInteraction) { + context.getString(R.string.widget_menu_disable_interaction) + } else { + context.getString(R.string.widget_menu_enable_interaction) + } + ).setOnMenuItemClickListener { _ -> + widget.allowInteraction = !widget.allowInteraction + updateWidget(widget) + return@setOnMenuItemClickListener true + } + } + menu.show() + } + + fun getHandles(): List { + return listOf( + Handle(WidgetManagerView.EditMode.TOP, + Rect(HANDLE_EDGE_SIZE, 0, width - HANDLE_EDGE_SIZE, HANDLE_SIZE)), + Handle(WidgetManagerView.EditMode.BOTTOM, + Rect(HANDLE_EDGE_SIZE, height - HANDLE_SIZE, width - HANDLE_EDGE_SIZE, height)), + Handle(WidgetManagerView.EditMode.LEFT, + Rect(0, HANDLE_EDGE_SIZE, HANDLE_SIZE, height - HANDLE_EDGE_SIZE)), + Handle(WidgetManagerView.EditMode.RIGHT, + Rect(width - HANDLE_SIZE, HANDLE_EDGE_SIZE, width, height - HANDLE_EDGE_SIZE)) + ) + + } + + private fun getBounds(): Rect { + return Rect(0,0, width, height) + } +} \ No newline at end of file diff --git a/app/src/main/java/de/jrpie/android/launcher/ui/widgets/manage/WidgetPanelsRecyclerAdapter.kt b/app/src/main/java/de/jrpie/android/launcher/ui/widgets/manage/WidgetPanelsRecyclerAdapter.kt new file mode 100644 index 0000000..40c2c2f --- /dev/null +++ b/app/src/main/java/de/jrpie/android/launcher/ui/widgets/manage/WidgetPanelsRecyclerAdapter.kt @@ -0,0 +1,98 @@ +package de.jrpie.android.launcher.ui.widgets.manage + +import android.content.Context +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.EditText +import android.widget.PopupMenu +import android.widget.TextView +import androidx.appcompat.app.AlertDialog +import androidx.recyclerview.widget.RecyclerView +import de.jrpie.android.launcher.R +import de.jrpie.android.launcher.preferences.LauncherPreferences +import de.jrpie.android.launcher.widgets.WidgetPanel +import de.jrpie.android.launcher.widgets.updateWidgetPanel + + +class WidgetPanelsRecyclerAdapter( + val context: Context, + val showMenu: Boolean = false, + val onSelectWidgetPanel: (WidgetPanel) -> Unit +) : + RecyclerView.Adapter() { + + var widgetPanels = (LauncherPreferences.widgets().customPanels() ?: setOf()).toTypedArray() + + class ViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) { + var labelView: TextView = itemView.findViewById(R.id.list_widget_panels_label) + } + + override fun onBindViewHolder(viewHolder: ViewHolder, i: Int) { + viewHolder.labelView.text = widgetPanels[i].label + + viewHolder.itemView.setOnClickListener { + onSelectWidgetPanel(widgetPanels[i]) + } + + if (showMenu) { + viewHolder.itemView.setOnLongClickListener { + showOptionsPopup( + viewHolder, + widgetPanels[i] + ) + } + } + } + + @Suppress("SameReturnValue") + private fun showOptionsPopup( + viewHolder: ViewHolder, + widgetPanel: WidgetPanel + ): Boolean { + //create the popup menu + + val popup = PopupMenu(context, viewHolder.labelView) + popup.menu.add(R.string.manage_widget_panels_delete).setOnMenuItemClickListener { _ -> + widgetPanel.delete(context) + true + } + popup.menu.add(R.string.manage_widget_panels_rename).setOnMenuItemClickListener { _ -> + AlertDialog.Builder(context, R.style.AlertDialogCustom).apply { + setNegativeButton(R.string.dialog_cancel) { _, _ -> } + setPositiveButton(R.string.dialog_ok) { dialogInterface, _ -> + var newLabel = (dialogInterface as? AlertDialog) + ?.findViewById(R.id.dialog_rename_widget_panel_edit_text) + ?.text?.toString() + if (newLabel == null || newLabel.isEmpty()) { + newLabel = + (context.getString(R.string.widget_panel_default_name, widgetPanel.id)) + } + widgetPanel.label = newLabel + updateWidgetPanel(widgetPanel) + } + setView(R.layout.dialog_rename_widget_panel) + }.create().also { it.show() }.apply { + findViewById(R.id.dialog_rename_widget_panel_edit_text)?.let { + it.setText(widgetPanel.label) + it.hint = context.getString(R.string.widget_panel_default_name, widgetPanel.id) + } + } + true + } + + popup.show() + return true + } + + override fun getItemCount(): Int { + return widgetPanels.size + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder { + val view: View = + LayoutInflater.from(context).inflate(R.layout.list_widget_panels_row, parent, false) + val viewHolder = ViewHolder(view) + return viewHolder + } +} \ No newline at end of file diff --git a/app/src/main/java/de/jrpie/android/launcher/widgets/AppWidget.kt b/app/src/main/java/de/jrpie/android/launcher/widgets/AppWidget.kt new file mode 100644 index 0000000..22a63eb --- /dev/null +++ b/app/src/main/java/de/jrpie/android/launcher/widgets/AppWidget.kt @@ -0,0 +1,126 @@ +package de.jrpie.android.launcher.widgets + +import android.app.Activity +import android.appwidget.AppWidgetHostView +import android.appwidget.AppWidgetProviderInfo +import android.content.Context +import android.graphics.drawable.Drawable +import android.os.Build +import android.os.Bundle +import android.util.DisplayMetrics +import android.util.SizeF +import android.view.View +import de.jrpie.android.launcher.Application +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +@SerialName("widget:app") +class AppWidget( + override val id: Int, + override var position: WidgetPosition = WidgetPosition(0,0,1,1), + override var panelId: Int = WidgetPanel.HOME.id, + override var allowInteraction: Boolean = false, + + // We keep track of packageName, className and user to make it possible to restore the widget + // on a new device when restoring settings (currently not implemented) + // In normal operation only id and position are used. + val packageName: String? = null, + val className: String? = null, + val user: Int? = null +): Widget() { + + + constructor( + id: Int, + position: WidgetPosition, + panelId: Int, + widgetProviderInfo: AppWidgetProviderInfo + ) : + this( + id, + position, + panelId, + false, + widgetProviderInfo.provider.packageName, + widgetProviderInfo.provider.className, + widgetProviderInfo.profile.hashCode() + ) + + /** + * Get the [AppWidgetProviderInfo] by [id]. + * If the widget is not installed, use [restoreAppWidgetProviderInfo] instead. + */ + fun getAppWidgetProviderInfo(context: Context): AppWidgetProviderInfo? { + if (id < 0) { + return null + } + return (context.applicationContext as Application).appWidgetManager + .getAppWidgetInfo(id) + } + + /** + * Restore the AppWidgetProviderInfo from [user], [packageName] and [className]. + * Only use this when the widget is not installed, + * in normal operation use [getAppWidgetProviderInfo] instead. + */ + /*fun restoreAppWidgetProviderInfo(context: Context): AppWidgetProviderInfo? { + return getAppWidgetProviders(context).firstOrNull { + it.profile.hashCode() == user + && it.provider.packageName == packageName + && it.provider.className == className + } + }*/ + + override fun toString(): String { + return "WidgetInfo(id=$id, position=$position, packageName=$packageName, className=$className, user=$user)" + } + + override fun createView(activity: Activity): AppWidgetHostView? { + val providerInfo = activity.getAppWidgetManager().getAppWidgetInfo(id) ?: return null + val view = activity.getAppWidgetHost() + .createView(activity, this.id, providerInfo) + + val dp = activity.resources.displayMetrics.density + val screenWidth = activity.resources.displayMetrics.widthPixels + val screenHeight = activity.resources.displayMetrics.heightPixels + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + val absolutePosition = position.getAbsoluteRect(screenWidth, screenHeight) + view.updateAppWidgetSize(Bundle.EMPTY, + listOf(SizeF( + absolutePosition.width() / dp, + absolutePosition.height() / dp + ))) + } + view.setPadding(0,0,0,0) + return view + } + + override fun findView(views: Sequence): AppWidgetHostView? { + return views.mapNotNull { it as? AppWidgetHostView }.firstOrNull { it.appWidgetId == id } + } + + override fun getIcon(context: Context): Drawable? { + return context.getAppWidgetManager().getAppWidgetInfo(id)?.loadIcon(context, DisplayMetrics.DENSITY_HIGH) + } + + override fun getPreview(context: Context): Drawable? { + return context.getAppWidgetManager().getAppWidgetInfo(id)?.loadPreviewImage(context, DisplayMetrics.DENSITY_HIGH) + } + + override fun isConfigurable(context: Context): Boolean { + return context.getAppWidgetManager().getAppWidgetInfo(id)?.configure != null + } + override fun configure(activity: Activity, requestCode: Int) { + if (!isConfigurable(activity)) { + return + } + activity.getAppWidgetHost().startAppWidgetConfigureActivityForResult( + activity, + id, + 0, + requestCode, + null + ) + } +} diff --git a/app/src/main/java/de/jrpie/android/launcher/widgets/ClockWidget.kt b/app/src/main/java/de/jrpie/android/launcher/widgets/ClockWidget.kt new file mode 100644 index 0000000..d0d1c0e --- /dev/null +++ b/app/src/main/java/de/jrpie/android/launcher/widgets/ClockWidget.kt @@ -0,0 +1,42 @@ +package de.jrpie.android.launcher.widgets + +import android.app.Activity +import android.content.Context +import android.graphics.drawable.Drawable +import android.view.View +import de.jrpie.android.launcher.ui.widgets.ClockView +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + + +@Serializable +@SerialName("widget:clock") +class ClockWidget( + override val id: Int, + override var position: WidgetPosition, + override val panelId: Int, + override var allowInteraction: Boolean = true +) : Widget() { + + override fun createView(activity: Activity): View? { + return ClockView(activity, null, id) + } + + override fun findView(views: Sequence): ClockView? { + return views.mapNotNull { it as? ClockView }.firstOrNull { it.appWidgetId == id } + } + + override fun getPreview(context: Context): Drawable? { + return null + } + + override fun getIcon(context: Context): Drawable? { + return null + } + + override fun isConfigurable(context: Context): Boolean { + return false + } + + override fun configure(activity: Activity, requestCode: Int) { } +} \ No newline at end of file diff --git a/app/src/main/java/de/jrpie/android/launcher/widgets/LauncherWidgetProvider.kt b/app/src/main/java/de/jrpie/android/launcher/widgets/LauncherWidgetProvider.kt new file mode 100644 index 0000000..018b29b --- /dev/null +++ b/app/src/main/java/de/jrpie/android/launcher/widgets/LauncherWidgetProvider.kt @@ -0,0 +1,58 @@ +package de.jrpie.android.launcher.widgets + +import android.appwidget.AppWidgetProviderInfo +import android.content.Context +import android.graphics.drawable.Drawable +import android.os.Build +import android.util.DisplayMetrics +import androidx.appcompat.content.res.AppCompatResources +import de.jrpie.android.launcher.R + +sealed class LauncherWidgetProvider { + abstract fun loadLabel(context: Context): CharSequence? + abstract fun loadPreviewImage(context: Context): Drawable? + abstract fun loadIcon(context: Context): Drawable? + abstract fun loadDescription(context: Context): CharSequence? +} + +class LauncherAppWidgetProvider(val info: AppWidgetProviderInfo) : LauncherWidgetProvider() { + + override fun loadLabel(context: Context): CharSequence? { + return info.loadLabel(context.packageManager) + } + override fun loadPreviewImage(context: Context): Drawable? { + return info.loadPreviewImage(context, DisplayMetrics.DENSITY_DEFAULT) + } + + override fun loadIcon(context: Context): Drawable? { + return info.loadIcon(context, DisplayMetrics.DENSITY_DEFAULT) + } + + override fun loadDescription(context: Context): CharSequence? { + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + info.loadDescription(context) + } else { + null + } + } + +} +class LauncherClockWidgetProvider : LauncherWidgetProvider() { + + override fun loadLabel(context: Context): CharSequence? { + return context.getString(R.string.widget_clock_label) + } + + override fun loadDescription(context: Context): CharSequence? { + return context.getString(R.string.widget_clock_description) + } + + override fun loadPreviewImage(context: Context): Drawable? { + return null + } + + override fun loadIcon(context: Context): Drawable? { + return AppCompatResources.getDrawable(context, R.drawable.baseline_clock_24) + } +} + diff --git a/app/src/main/java/de/jrpie/android/launcher/widgets/Widget.kt b/app/src/main/java/de/jrpie/android/launcher/widgets/Widget.kt new file mode 100644 index 0000000..dbe667b --- /dev/null +++ b/app/src/main/java/de/jrpie/android/launcher/widgets/Widget.kt @@ -0,0 +1,65 @@ +package de.jrpie.android.launcher.widgets + +import android.app.Activity +import android.content.Context +import android.graphics.drawable.Drawable +import android.view.View +import de.jrpie.android.launcher.Application +import de.jrpie.android.launcher.preferences.LauncherPreferences +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json + + +@Serializable +sealed class Widget { + abstract val id: Int + abstract var position: WidgetPosition + abstract val panelId: Int + abstract var allowInteraction: Boolean + + /** + * @param activity The activity where the view will be used. Must not be an AppCompatActivity. + */ + abstract fun createView(activity: Activity): View? + abstract fun findView(views: Sequence): View? + abstract fun getPreview(context: Context): Drawable? + abstract fun getIcon(context: Context): Drawable? + abstract fun isConfigurable(context: Context): Boolean + abstract fun configure(activity: Activity, requestCode: Int) + + fun delete(context: Context) { + context.getAppWidgetHost().deleteAppWidgetId(id) + + LauncherPreferences.widgets().widgets( + LauncherPreferences.widgets().widgets()?.also { + it.remove(this) + } + ) + } + + fun getPanel(): WidgetPanel? { + return WidgetPanel.byId(panelId) + } + + override fun hashCode(): Int { + return id + } + + override fun equals(other: Any?): Boolean { + return (other as? Widget)?.id == id + } + + fun serialize(): String { + return Json.encodeToString(serializer(), this) + } + companion object { + fun deserialize(serialized: String): Widget { + return Json.decodeFromString(serialized) + } + fun byId(context: Context, id: Int): Widget? { + return (context.applicationContext as Application).widgets.value?.firstOrNull { + it.id == id + } + } + } +} diff --git a/app/src/main/java/de/jrpie/android/launcher/widgets/WidgetPanel.kt b/app/src/main/java/de/jrpie/android/launcher/widgets/WidgetPanel.kt new file mode 100644 index 0000000..93e588d --- /dev/null +++ b/app/src/main/java/de/jrpie/android/launcher/widgets/WidgetPanel.kt @@ -0,0 +1,58 @@ +package de.jrpie.android.launcher.widgets + +import android.content.Context +import de.jrpie.android.launcher.preferences.LauncherPreferences +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json + + +@Serializable +@SerialName("panel") +class WidgetPanel(val id: Int, var label: String) { + + override fun equals(other: Any?): Boolean { + return (other as? WidgetPanel)?.id == id + } + + override fun hashCode(): Int { + return id + } + + fun serialize(): String { + return Json.encodeToString(this) + } + + fun delete(context: Context) { + LauncherPreferences.widgets().customPanels( + (LauncherPreferences.widgets().customPanels() ?: setOf()).minus(this) + ) + (LauncherPreferences.widgets().widgets() ?: return) + .filter { it.panelId == this.id }.forEach { it.delete(context) } + } + + + companion object { + val HOME = WidgetPanel(0, "home") + fun byId(id: Int): WidgetPanel? { + if (id == 0) { + return HOME + } + return LauncherPreferences.widgets().customPanels()?.firstOrNull { it.id == id } + } + + fun allocateId(): Int { + return ( + (LauncherPreferences.widgets().customPanels() ?: setOf()) + .plus(HOME) + .maxOfOrNull { it.id } ?: 0 + ) + 1 + } + + fun deserialize(serialized: String): WidgetPanel { + return Json.decodeFromString(serialized) + } + + } +} \ No newline at end of file diff --git a/app/src/main/java/de/jrpie/android/launcher/widgets/WidgetPosition.kt b/app/src/main/java/de/jrpie/android/launcher/widgets/WidgetPosition.kt new file mode 100644 index 0000000..b575665 --- /dev/null +++ b/app/src/main/java/de/jrpie/android/launcher/widgets/WidgetPosition.kt @@ -0,0 +1,58 @@ +package de.jrpie.android.launcher.widgets + +import android.graphics.Rect +import kotlinx.serialization.Serializable +import kotlin.math.ceil +import kotlin.math.roundToInt +import kotlin.math.max + +const val GRID_SIZE: Short = 12 + +@Serializable +data class WidgetPosition(var x: Short, var y: Short, var width: Short, var height: Short) { + + fun getAbsoluteRect(screenWidth: Int, screenHeight: Int): Rect { + val gridWidth = screenWidth / GRID_SIZE.toFloat() + val gridHeight= screenHeight / GRID_SIZE.toFloat() + + return Rect( + (x * gridWidth).toInt(), + (y * gridHeight).toInt(), + ((x + width) * gridWidth).toInt(), + ((y + height) * gridHeight).toInt() + ) + } + + companion object { + fun fromAbsoluteRect(absolute: Rect, screenWidth: Int, screenHeight: Int): WidgetPosition { + val gridWidth = screenWidth / GRID_SIZE.toFloat() + val gridHeight= screenHeight / GRID_SIZE.toFloat() + + val x = (absolute.left / gridWidth).roundToInt().toShort().coerceIn(0, (GRID_SIZE-1).toShort()) + val y = (absolute.top / gridHeight).roundToInt().toShort().coerceIn(0, (GRID_SIZE-1).toShort()) + + + val w = max(2, ((absolute.right - absolute.left) / gridWidth).roundToInt()).toShort() + val h = max(2, ((absolute.bottom - absolute.top) / gridHeight).roundToInt()).toShort() + + return WidgetPosition(x,y,w,h) + + } + + fun center(minWidth: Int, minHeight: Int, screenWidth: Int, screenHeight: Int): WidgetPosition { + val gridWidth = screenWidth / GRID_SIZE.toFloat() + val gridHeight= screenHeight / GRID_SIZE.toFloat() + + val cellsWidth = ceil(minWidth / gridWidth).toInt().toShort() + val cellsHeight = ceil(minHeight / gridHeight).toInt().toShort() + + return WidgetPosition( + ((GRID_SIZE - cellsWidth) / 2).toShort(), + ((GRID_SIZE - cellsHeight) / 2).toShort(), + cellsWidth, + cellsHeight + ) + + } + } +} \ No newline at end of file diff --git a/app/src/main/java/de/jrpie/android/launcher/widgets/Widgets.kt b/app/src/main/java/de/jrpie/android/launcher/widgets/Widgets.kt new file mode 100644 index 0000000..b7f140b --- /dev/null +++ b/app/src/main/java/de/jrpie/android/launcher/widgets/Widgets.kt @@ -0,0 +1,97 @@ +package de.jrpie.android.launcher.widgets + +import android.app.Activity +import android.app.Service +import android.appwidget.AppWidgetHost +import android.appwidget.AppWidgetManager +import android.appwidget.AppWidgetProviderInfo +import android.content.Context +import android.content.Intent +import android.content.pm.LauncherApps +import android.os.Build +import android.os.UserManager +import android.util.Log +import de.jrpie.android.launcher.Application +import de.jrpie.android.launcher.preferences.LauncherPreferences + +fun deleteAllWidgets(context: Context) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + context.getAppWidgetHost().appWidgetIds.forEach { AppWidget(it).delete(context) } + } +} + +/** + * Tries to bind [providerInfo] to the id [id]. + * @param providerInfo The widget to be bound. + * @param id The id to bind the widget to. If -1 is provided, a new id is allocated. + * @param + * @param requestCode Used to start an activity to request permission to bind the widget. + * + * @return true iff the app widget was bound successfully. + */ +fun bindAppWidgetOrRequestPermission(activity: Activity, providerInfo: AppWidgetProviderInfo, id: Int, requestCode: Int? = null): Boolean { + val appWidgetId = if(id == -1) { + activity.getAppWidgetHost().allocateAppWidgetId() + } else { id } + + Log.i("Launcher", "Binding new widget ${appWidgetId}") + if (!activity.getAppWidgetManager().bindAppWidgetIdIfAllowed( + appWidgetId, + providerInfo.provider + ) + ) { + Log.i("Widgets", "requesting permission for widget") + val intent = Intent(AppWidgetManager.ACTION_APPWIDGET_BIND).apply { + putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID,appWidgetId) + putExtra(AppWidgetManager.EXTRA_APPWIDGET_PROVIDER, providerInfo.provider) + } + activity.startActivityForResult(intent, requestCode ?: 0) + return false + } + return true +} + + +fun getAppWidgetProviders( context: Context ): List { + val list = mutableListOf(LauncherClockWidgetProvider()) + val appWidgetManager = context.getAppWidgetManager() + val profiles = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + (context.getSystemService(Service.LAUNCHER_APPS_SERVICE) as LauncherApps).profiles + } else { + (context.getSystemService(Service.USER_SERVICE) as UserManager).userProfiles + } + list.addAll( + profiles.map { + appWidgetManager.getInstalledProvidersForProfile(it) + .map { LauncherAppWidgetProvider(it) } + }.flatten() + ) + + + return list +} + + +fun updateWidget(widget: Widget) { + LauncherPreferences.widgets().widgets( + (LauncherPreferences.widgets().widgets() ?: setOf()) + .minus(widget) + .plus(widget) + ) +} + +fun updateWidgetPanel(widgetPanel: WidgetPanel) { + LauncherPreferences.widgets().customPanels( + (LauncherPreferences.widgets().customPanels() ?: setOf()) + .minus(widgetPanel) + .plus(widgetPanel) + ) +} + +fun Context.getAppWidgetHost(): AppWidgetHost { + return (this.applicationContext as Application).appWidgetHost +} +fun Context.getAppWidgetManager(): AppWidgetManager { + return (this.applicationContext as Application).appWidgetManager +} diff --git a/app/src/main/res/drawable-mdpi/tutorial_app_list.png b/app/src/main/res/drawable-mdpi/tutorial_app_list.png new file mode 100644 index 0000000..66ebaaf Binary files /dev/null and b/app/src/main/res/drawable-mdpi/tutorial_app_list.png differ diff --git a/app/src/main/res/drawable-mdpi/tutorial_home_screen.png b/app/src/main/res/drawable-mdpi/tutorial_home_screen.png new file mode 100644 index 0000000..ccbce5d Binary files /dev/null and b/app/src/main/res/drawable-mdpi/tutorial_home_screen.png differ diff --git a/app/src/main/res/drawable/baseline_add_24.xml b/app/src/main/res/drawable/baseline_add_24.xml new file mode 100644 index 0000000..13267ce --- /dev/null +++ b/app/src/main/res/drawable/baseline_add_24.xml @@ -0,0 +1,11 @@ + + + + + diff --git a/app/src/main/res/drawable/baseline_apps_24.xml b/app/src/main/res/drawable/baseline_apps_24.xml new file mode 100644 index 0000000..c5a49a0 --- /dev/null +++ b/app/src/main/res/drawable/baseline_apps_24.xml @@ -0,0 +1,11 @@ + + + + + diff --git a/app/src/main/res/drawable/baseline_clock_24.xml b/app/src/main/res/drawable/baseline_clock_24.xml new file mode 100644 index 0000000..7968998 --- /dev/null +++ b/app/src/main/res/drawable/baseline_clock_24.xml @@ -0,0 +1,15 @@ + + + + + + + diff --git a/app/src/main/res/drawable/baseline_home_24.xml b/app/src/main/res/drawable/baseline_home_24.xml new file mode 100644 index 0000000..935d1b6 --- /dev/null +++ b/app/src/main/res/drawable/baseline_home_24.xml @@ -0,0 +1,11 @@ + + + + + diff --git a/app/src/main/res/drawable/baseline_more_horiz_24.xml b/app/src/main/res/drawable/baseline_more_horiz_24.xml deleted file mode 100644 index 061fae2..0000000 --- a/app/src/main/res/drawable/baseline_more_horiz_24.xml +++ /dev/null @@ -1,11 +0,0 @@ - - - - - diff --git a/app/src/main/res/drawable/baseline_navigate_before_24.xml b/app/src/main/res/drawable/baseline_navigate_before_24.xml new file mode 100644 index 0000000..4097b26 --- /dev/null +++ b/app/src/main/res/drawable/baseline_navigate_before_24.xml @@ -0,0 +1,11 @@ + + + + + diff --git a/app/src/main/res/drawable/baseline_navigate_next_24.xml b/app/src/main/res/drawable/baseline_navigate_next_24.xml new file mode 100644 index 0000000..22cef28 --- /dev/null +++ b/app/src/main/res/drawable/baseline_navigate_next_24.xml @@ -0,0 +1,11 @@ + + + + + diff --git a/app/src/main/res/drawable/baseline_question_mark_24.xml b/app/src/main/res/drawable/baseline_question_mark_24.xml new file mode 100644 index 0000000..9a2b28a --- /dev/null +++ b/app/src/main/res/drawable/baseline_question_mark_24.xml @@ -0,0 +1,12 @@ + + + + + diff --git a/app/src/main/res/drawable/baseline_volume_adjust_24.xml b/app/src/main/res/drawable/baseline_volume_adjust_24.xml new file mode 100644 index 0000000..38e6a8b --- /dev/null +++ b/app/src/main/res/drawable/baseline_volume_adjust_24.xml @@ -0,0 +1,16 @@ + + + + + + diff --git a/app/src/main/res/drawable/baseline_widgets_24.xml b/app/src/main/res/drawable/baseline_widgets_24.xml new file mode 100644 index 0000000..fd0f571 --- /dev/null +++ b/app/src/main/res/drawable/baseline_widgets_24.xml @@ -0,0 +1,11 @@ + + + + + diff --git a/app/src/main/res/drawable/home_round_screen.png b/app/src/main/res/drawable/home_round_screen.png deleted file mode 100644 index f0237e8..0000000 Binary files a/app/src/main/res/drawable/home_round_screen.png and /dev/null differ diff --git a/app/src/main/res/drawable/ic_launcher_background.xml b/app/src/main/res/drawable/ic_launcher_background.xml deleted file mode 100644 index 4f1c4ab..0000000 --- a/app/src/main/res/drawable/ic_launcher_background.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - - diff --git a/app/src/main/res/layout/activity_manage_widget_panels.xml b/app/src/main/res/layout/activity_manage_widget_panels.xml new file mode 100644 index 0000000..60413a3 --- /dev/null +++ b/app/src/main/res/layout/activity_manage_widget_panels.xml @@ -0,0 +1,73 @@ + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/activity_manage_widgets.xml b/app/src/main/res/layout/activity_manage_widgets.xml new file mode 100644 index 0000000..c77f0e3 --- /dev/null +++ b/app/src/main/res/layout/activity_manage_widgets.xml @@ -0,0 +1,25 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/activity_pin_shortcut.xml b/app/src/main/res/layout/activity_pin_shortcut.xml index c401b42..2519374 100644 --- a/app/src/main/res/layout/activity_pin_shortcut.xml +++ b/app/src/main/res/layout/activity_pin_shortcut.xml @@ -80,7 +80,6 @@ android:minHeight="40dp" tools:drawableLeft="@drawable/baseline_settings_24" tools:text="Shortcut name" /> - + + + +