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/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md deleted file mode 100644 index d14b126..0000000 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ /dev/null @@ -1,38 +0,0 @@ ---- -name: Bug report -about: Create a report to help improve this app -title: '[bug] ' -labels: bug -assignees: '' - ---- - -# Describe the bug - - - -# To Reproduce - - - -# Expected behavior - - - -# Screenshots - - -# Smartphone (please complete the following information) - - Device: - - Android Version: - - µLauncher Version: - -# Additional info - - diff --git a/.github/ISSUE_TEMPLATE/bug_report.yaml b/.github/ISSUE_TEMPLATE/bug_report.yaml new file mode 100644 index 0000000..fa112ae --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yaml @@ -0,0 +1,50 @@ +name: Bug report +description: Create a report to help improve this app +title: '[bug] ' +labels: bug +body: + - type: markdown + attributes: + value: | + Thank you for helping to improve µLauncher! + - type: textarea + id: bug + attributes: + label: Describe the Bug + description: What happened? + placeholder: A clear and concise description of what the bug is. + render: markdown + validations: + required: true + - type: textarea + id: expected + attributes: + label: Expected Behavior + description: What did you expect to happen instead? + render: markdown + validations: + required: false + - type: textarea + id: reproduce + attributes: + label: To Reproduce + description: What steps are required to reproduce the bug? + render: markdown + placeholder: | + Steps to reproduce the behavior: + 1. Go to '...' + 2. Click on '....' + 3. Scroll down to '....' + 4. See error + validations: + required: false + - type: textarea + id: device + attributes: + label: Your Device + description: | + What device are you using? Adding this information helps to reproduce the bug. + You can copy this from µLauncher > Settings > Meta > Report Bug. + render: markdown + validations: + required: false diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml index 912995a..ba9a709 100644 --- a/.github/workflows/android.yml +++ b/.github/workflows/android.yml @@ -29,4 +29,4 @@ jobs: uses: actions/upload-artifact@v4 with: name: launcher-debug-${{ github.sha }}.apk - path: app/build/outputs/apk/debug/app-debug.apk + path: app/build/outputs/apk/default/debug/app-default-debug.apk diff --git a/.scripts/release.sh b/.scripts/release.sh new file mode 100755 index 0000000..f207c87 --- /dev/null +++ b/.scripts/release.sh @@ -0,0 +1,76 @@ +#!/bin/bash +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" +KEYSTORE_ACCRESCENT="$HOME/data/keys/launcher_jrpie_accrescent.jks" +KEYSTORE_PASS=$(keepassxc-password "android_keys/launcher") +KEYSTORE_ACCRESCENT_PASS=$(keepassxc-password "android_keys/launcher-accrescent") + +if [[ $(git status --porcelain) ]]; then + echo "There are uncommitted changes." + + read -p "Continue anyway? (y/n) " -n 1 -r + echo # (optional) move to a new line + if ! [[ $REPLY =~ ^[Yy]$ ]] + then + exit 1 + fi + +fi + +rm -rf "$OUTPUT_DIR" +mkdir "$OUTPUT_DIR" + + +echo +echo "=======================" +echo " Default Release (apk) " +echo "=======================" + +./gradlew clean +./gradlew assembleDefaultRelease +mv app/build/outputs/apk/default/release/app-default-release-unsigned.apk "$OUTPUT_DIR/app-release.apk" +"$BUILD_TOOLS_DIR/apksigner" sign --ks "$KEYSTORE" \ + --ks-key-alias key0 \ + --ks-pass="pass:$KEYSTORE_PASS" \ + --key-pass="pass:$KEYSTORE_PASS" \ + --alignment-preserved \ + --v1-signing-enabled=true \ + --v2-signing-enabled=true \ + --v3-signing-enabled=true \ + --v4-signing-enabled=true \ + "$OUTPUT_DIR/app-release.apk" + +echo +echo "=======================" +echo " Default Release (aab) " +echo "=======================" + +./gradlew clean +./gradlew bundleDefaultRelease +mv app/build/outputs/bundle/defaultRelease/app-default-release.aab "$OUTPUT_DIR/app-release.aab" +"$BUILD_TOOLS_DIR/apksigner" sign --ks "$KEYSTORE" \ + --ks-key-alias key0 \ + --ks-pass="pass:$KEYSTORE_PASS" \ + --key-pass="pass:$KEYSTORE_PASS" \ + --v1-signing-enabled=true --v2-signing-enabled=true --v3-signing-enabled=true --v4-signing-enabled=true \ + --min-sdk-version=21 \ + "$OUTPUT_DIR/app-release.aab" + +echo +echo "=======================" +echo " Accrescent (apks) " +echo "=======================" + +./gradlew clean +./gradlew bundleAccrescentRelease +mv app/build/outputs/bundle/accrescentRelease/app-accrescent-release.aab "$OUTPUT_DIR/app-accrescent-release.aab" + +# build apks using bundletool from https://github.com/google/bundletool/releases +"$JAVA_HOME/bin/java" -jar /opt/android/bundletool.jar build-apks \ + --bundle="$OUTPUT_DIR/app-accrescent-release.aab" --output="$OUTPUT_DIR/launcher-accrescent.apks" \ + --ks="$KEYSTORE_ACCRESCENT" \ + --ks-pass="pass:$KEYSTORE_ACCRESCENT_PASS" \ + --ks-key-alias="key0" \ + --key-pass="pass:$KEYSTORE_ACCRESCENT_PASS" diff --git a/LICENSE b/LICENSE index a0bb980..7435f65 100644 --- a/LICENSE +++ b/LICENSE @@ -1,7 +1,7 @@ MIT License Original Code Copyright (c) 2020 Finn Glas (https://github.com/finnmglas/Launcher) -Modifications Copyright (c) 2023 Josia Pietsch +Modifications Copyright (c) 2025 Josia Pietsch Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/README.md b/README.md index b6c1f9d..ce1d0d0 100644 --- a/README.md +++ b/README.md @@ -14,22 +14,12 @@ µLauncher is an Android home screen that lets you launch apps using swipe gestures and button presses. It is *minimal, efficient and free of distraction*. -Your home screen only displays the date, time and a wallpaper. -Pressing back or swiping up (this can be configured) opens a list -of all installed apps, which can be searched efficiently. -This is a fork of [finnmglas's app Launcher][original-repo]. +Get it on F-Droid +Get it on Accrescent +Get it on Obtainium +Get it on GitHub - - - Get it on F-Droid - - Get it on Obtainium - - -or download the latest APK from the [releases section](https://github.com/jrpie/Launcher/releases/latest). You can also [get it on Google Play](https://play.google.com/store/apps/details?id=de.jrpie.android.launcher), but I don't recommend that. +µLauncher is a fork of [finnmglas's app Launcher][original-repo]. +An incomplete list of changes can be found [here](docs/launcher.md). + +## Features + +µLauncher only displays the date, time and a wallpaper. +Pressing back or swiping up (this can be configured) opens a list +of all installed apps, which can be searched efficiently. + +The following gestures are available: + - volume up / down, + - swipe up / down / left / right, + - swipe with two fingers, + - swipe on the left / right resp. top / bottom edge, + - tap, then swipe up / down / left / right, + - draw < / > / V / Λ + - click on date / time, + - double click, + - long click, + - back button. + +To every gesture you can bind one of the following actions: + - launch an app, + - open a list of all / favorite / private apps, + - open µLauncher settings, + - toggle private space lock, + - lock the screen, + - toggle the torch, + - volume up / down, + - go to previous / next audio track. + + + +µLauncher is compatible with [work profile](https://www.android.com/enterprise/work-profile/), +so apps like [Shelter](https://gitea.angry.im/PeterCxy/Shelter) can be used. + +By default the font is set to [Hack][hack-font], but other fonts can be selected. + + + ## Contributing There are several ways to contribute to this app: @@ -63,36 +93,14 @@ There are several ways to contribute to this app: * If you find a bug or have an idea for a new feature you can [join the chat][chat] or open an [issue][issues]. Please note that I work on this project in my free time. Thus I might not respond immediately and not all ideas will be implemented. * You can implement a new feature yourself: - Create a fork of this repository: [![][shield-gh-fork]][fork] - - Create a new branch named `feature/` of `fix/` and commit your changes. + - Create a new branch named `feature/` or `fix/` and commit your changes. - Open a new pull request. -See [BUILD.md](BUILD.md) for instructions how to build this project. +See [build.md](docs/build.md) for instructions how to build this project. +The [CI pipeline](https://github.com/jrpie/Launcher/actions) automatically creates debug builds. +Note that those are not signed. -## Notable changes compared to [Finn's Launcher][original-repo]: - -* Edge gestures: There is a setting to allow distinguishing swiping at the edges of the screen from swiping in the center. -* Compatible with [work profile](https://www.android.com/enterprise/work-profile/), so apps like [Shelter](https://gitea.angry.im/PeterCxy/Shelter) can be used. -* The home button now works as expected. - -### Visual -* This app uses the system wallpaper instead of a custom solution. -* The font has been changed to [Hack][hack-font]. -* Font Awesome Icons were replaced by Material icons. -* The gear button on the home screen was removed. Instead pressing back opens the list of applications and the app settings are accessible from there. - - -### Search -* The search algorithm was modified to prefer matches at the beginning of the app name, i.e. when searching for `"te"`, `"termux"` is sorted before `"notes"`. -* The search bar was moved to the bottom of the screen. - -### Technical -* Small improvements to the gesture detection. -* Different apps set as default. -* Package name was changed to `de.jrpie.android.launcher` to avoid clashing with the original app. -* Dropped support for API < 21 (i.e. pre Lollypop) -* Some refactoring ---- --- [hack-font]: https://sourcefoundry.org/hack/ [original-repo]: https://github.com/finnmglas/Launcher diff --git a/app/build.gradle b/app/build.gradle index d851ef5..eaf97f7 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -1,9 +1,9 @@ apply plugin: 'com.android.application' apply plugin: 'kotlin-android' apply plugin: 'kotlin-kapt' +apply plugin: 'kotlinx-serialization' android { - dataBinding { enabled = true } @@ -23,8 +23,8 @@ android { minSdkVersion 21 targetSdkVersion 35 compileSdk 35 - versionCode 34 - versionName "j-0.0.18" + versionCode 44 + versionName "0.1.4" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" } @@ -43,10 +43,37 @@ android { buildTypes { release { - // minifyEnabled true - // proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' + minifyEnabled true + proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' + } + debug { + applicationIdSuffix = ".debug" + versionNameSuffix = "-debug" } } + + flavorDimensions += "distribution" + + productFlavors { + create("default") { + dimension = "distribution" + getIsDefault().set(true) + buildConfigField "boolean", "USE_ACCESSIBILITY_SERVICE", "true" + } + create("accrescent") { + dimension = "distribution" + applicationIdSuffix = ".accrescent" + versionNameSuffix = "+accrescent" + buildConfigField "boolean", "USE_ACCESSIBILITY_SERVICE", "false" + } + } + + sourceSets { + accrescent { + manifest.srcFile 'src/accrescent/AndroidManifest.xml' + } + } + namespace 'de.jrpie.android.launcher' buildFeatures { buildConfig true @@ -58,25 +85,28 @@ 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-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' implementation 'androidx.gridlayout:gridlayout:1.0.0' implementation 'androidx.palette:palette-ktx:1.0.0' - implementation 'androidx.recyclerview:recyclerview:1.3.2' + implementation 'androidx.recyclerview:recyclerview:1.4.0' implementation 'androidx.preference:preference-ktx:1.2.1' 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/proguard-rules.pro b/app/proguard-rules.pro index 329090c..9e3e326 100644 --- a/app/proguard-rules.pro +++ b/app/proguard-rules.pro @@ -1,4 +1,6 @@ # Add project specific ProGuard rules here. +-dontobfuscate +-dontoptimize # You can control the set of applied configuration files using the # proguardFiles setting in build.gradle. # @@ -25,3 +27,4 @@ # This is generated automatically by the Android Gradle plugin. -dontwarn javax.annotation.processing.AbstractProcessor -dontwarn javax.annotation.processing.SupportedAnnotationTypes +-dontwarn javax.annotation.processing.SupportedSourceVersion diff --git a/app/src/accrescent/AndroidManifest.xml b/app/src/accrescent/AndroidManifest.xml new file mode 100644 index 0000000..16ea383 --- /dev/null +++ b/app/src/accrescent/AndroidManifest.xml @@ -0,0 +1,15 @@ + + + + + + + + diff --git a/app/src/debug/res/values/donottranslate.xml b/app/src/debug/res/values/donottranslate.xml new file mode 100644 index 0000000..bf4f4e4 --- /dev/null +++ b/app/src/debug/res/values/donottranslate.xml @@ -0,0 +1,3 @@ + + μLauncher [debug] + diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 466aefc..5a1d5a0 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -3,82 +3,112 @@ xmlns:tools="http://schemas.android.com/tools"> - - + - - + tools:ignore="UnusedAttribute"> + + + + + + + + + + + android:excludeFromRecents="true" + android:exported="true" + android:launchMode="singleTask" + android:theme="@style/launcherHomeTheme"> + - + android:name=".ui.tutorial.TutorialActivity" + android:configChanges="orientation|screenSize" /> - + android:windowSoftInputMode="adjustResize" /> + android:exported="true" + android:windowSoftInputMode="adjustNothing"> - - + + + + - - - + - + android:label="@string/app_name" + android:permission="android.permission.BIND_ACCESSIBILITY_SERVICE"> + - + + \ No newline at end of file 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 25e3e07..775621c 100644 --- a/app/src/main/java/de/jrpie/android/launcher/Application.kt +++ b/app/src/main/java/de/jrpie/android/launcher/Application.kt @@ -1,19 +1,108 @@ package de.jrpie.android.launcher +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter import android.content.SharedPreferences +import android.content.pm.LauncherApps +import android.content.pm.ShortcutInfo 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.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 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?) { + // TODO: only update specific apps + // use Intent.EXTRA_USER + loadApps() + } + } + + // TODO: only update specific apps + private val launcherAppsCallback = object : LauncherApps.Callback() { + override fun onPackageRemoved(p0: String?, p1: UserHandle?) { + loadApps() + } + + override fun onPackageAdded(p0: String?, p1: UserHandle?) { + loadApps() + } + + override fun onPackageChanged(p0: String?, p1: UserHandle?) { + loadApps() + } + + override fun onPackagesAvailable(p0: Array?, p1: UserHandle?, p2: Boolean) { + // TODO + } + + override fun onPackagesSuspended(packageNames: Array?, user: UserHandle?) { + // TODO + } + + override fun onPackagesUnsuspended(packageNames: Array?, user: UserHandle?) { + // TODO + } + + override fun onPackagesUnavailable(p0: Array?, p1: UserHandle?, p2: Boolean) { + // TODO + } + + override fun onPackageLoadingProgressChanged( + packageName: String, + user: UserHandle, + progress: Float + ) { + // TODO + } + + override fun onShortcutsChanged( + packageName: String, + shortcuts: MutableList, + user: UserHandle + ) { + // TODO + } + } + 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()) } } @@ -27,15 +116,69 @@ 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 + // The tutorial is started from HomeActivity#onStart, as starting it here is blocked by android + if (!LauncherPreferences.internal().started()) { + resetPreferences(this) + } + + LauncherPreferences.getSharedPreferences() .registerOnSharedPreferenceChangeListener(listener) + + + val launcherApps = getSystemService(LAUNCHER_APPS_SERVICE) as LauncherApps + launcherApps.registerCallback(launcherAppsCallback) + + if (Build.VERSION.SDK_INT >= VERSION_CODES.N) { + val filter = IntentFilter().also { + if (Build.VERSION.SDK_INT >= VERSION_CODES.VANILLA_ICE_CREAM) { + it.addAction(Intent.ACTION_PROFILE_AVAILABLE) + it.addAction(Intent.ACTION_PROFILE_UNAVAILABLE) + } else { + it.addAction(Intent.ACTION_MANAGED_PROFILE_AVAILABLE) + it.addAction(Intent.ACTION_MANAGED_PROFILE_UNAVAILABLE) + } + } + ContextCompat.registerReceiver( + this, profileAvailabilityBroadcastReceiver, filter, + ContextCompat.RECEIVER_EXPORTED + ) + } + + if (Build.VERSION.SDK_INT >= VERSION_CODES.N_MR1) { + removeUnusedShortcuts(this) + } + loadApps() } - fun getCustomAppNames(): HashMap { + fun getCustomAppNames(): HashMap { return (customAppNames ?: LauncherPreferences.apps().customNames() ?: HashMap()) .also { customAppNames = it } } + + private fun loadApps() { + privateSpaceLocked.postValue(isPrivateSpaceLocked(this)) + 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 cc1b982..9679ae5 100644 --- a/app/src/main/java/de/jrpie/android/launcher/Functions.kt +++ b/app/src/main/java/de/jrpie/android/launcher/Functions.kt @@ -3,147 +3,132 @@ package de.jrpie.android.launcher import android.app.Activity import android.app.Service 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.graphics.ColorMatrix -import android.graphics.ColorMatrixColorFilter -import android.graphics.Rect -import android.net.Uri +import android.content.pm.ShortcutInfo import android.os.Build import android.os.Bundle import android.os.UserHandle import android.os.UserManager import android.provider.Settings import android.util.Log -import android.view.View -import android.view.animation.AlphaAnimation -import android.view.animation.Animation -import android.view.inputmethod.InputMethodManager -import android.widget.ImageView +import android.widget.Toast +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.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.ui.list.apps.AppsRecyclerAdapter +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 -/* Objects used by multiple activities */ -val appsList: MutableList = ArrayList() - -/* 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" -/* Animate */ +const val REQUEST_SET_DEFAULT_HOME = 42 -// Taken from https://stackoverflow.com/questions/47293269 -fun View.blink( - times: Int = Animation.INFINITE, - duration: Long = 1000L, - offset: Long = 20L, - minAlpha: Float = 0.2f, - maxAlpha: Float = 1.0f, - repeatMode: Int = Animation.REVERSE -) { - startAnimation(AlphaAnimation(minAlpha, maxAlpha).also { - it.duration = duration - it.startOffset = offset - it.repeatMode = repeatMode - it.repeatCount = times - }) -} - -fun setDefaultHomeScreen(context: Context, checkDefault: Boolean = false) { - - if (checkDefault - && Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q - && context is Activity - ) { +fun isDefaultHomeScreen(context: Context): Boolean { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { val roleManager = context.getSystemService(RoleManager::class.java) - if (!roleManager.isRoleHeld(RoleManager.ROLE_HOME)) { - context.startActivityForResult( - roleManager.createRequestRoleIntent(RoleManager.ROLE_HOME), - REQUEST_SET_DEFAULT_HOME - ) - } - return - } - - if (checkDefault) { + return roleManager.isRoleHeld(RoleManager.ROLE_HOME) + } else { val testIntent = Intent(Intent.ACTION_MAIN) testIntent.addCategory(Intent.CATEGORY_HOME) val defaultHome = testIntent.resolveActivity(context.packageManager)?.packageName - if (defaultHome == context.packageName) { - // Launcher is already the default home app - return - } + return defaultHome == context.packageName } +} + +fun setDefaultHomeScreen(context: Context, checkDefault: Boolean = false) { + val isDefault = isDefaultHomeScreen(context) + if (checkDefault && isDefault) { + // Launcher is already the default home app + return + } + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q + && context is Activity + && checkDefault // using role manager only works when µLauncher is not already the default. + ) { + val roleManager = context.getSystemService(RoleManager::class.java) + context.startActivityForResult( + roleManager.createRequestRoleIntent(RoleManager.ROLE_HOME), + REQUEST_SET_DEFAULT_HOME + ) + return + } + val intent = Intent(Settings.ACTION_HOME_SETTINGS) context.startActivity(intent) } - -fun getIntent(packageName: String, context: Context): Intent? { - val intent: Intent? = context.packageManager.getLaunchIntentForPackage(packageName) - intent?.addCategory(Intent.CATEGORY_LAUNCHER) - return intent -} -/* --- */ - -fun getUserFromId(user: Int?, context: Context): UserHandle { +fun getUserFromId(userId: Int?, context: Context): UserHandle { /* TODO: this is an ugly hack. Use userManager#getUserForSerialNumber instead (breaking change to SharedPreferences!) */ val userManager = context.getSystemService(Service.USER_SERVICE) as UserManager val profiles = userManager.userProfiles - return profiles.firstOrNull { it.hashCode() == user } ?: profiles[0] + return profiles.firstOrNull { it.hashCode() == userId } ?: profiles[0] } - -fun uninstallApp(appInfo: AppInfo, activity: Activity) { - val packageName = appInfo.packageName.toString() - val user = appInfo.user - - Log.i(LOG_TAG, "uninstalling $appInfo") - val intent = Intent(Intent.ACTION_UNINSTALL_PACKAGE) - intent.data = Uri.parse("package:$packageName") - getUserFromId(user, activity).let { user -> - intent.putExtra(Intent.EXTRA_USER, user) +@RequiresApi(Build.VERSION_CODES.N_MR1) +fun removeUnusedShortcuts(context: Context) { + val launcherApps = context.getSystemService(Service.LAUNCHER_APPS_SERVICE) as LauncherApps + fun getShortcuts(profile: UserHandle): List? { + return try { + launcherApps.getShortcuts( + ShortcutQuery().apply { + setQueryFlags(ShortcutQuery.FLAG_MATCH_PINNED) + }, + profile + ) + } catch (e: Exception) { + // https://github.com/jrpie/launcher/issues/116 + return null + } } - intent.putExtra(Intent.EXTRA_RETURN_RESULT, true) - activity.startActivityForResult( - intent, - REQUEST_UNINSTALL - ) + val userManager = context.getSystemService(Service.USER_SERVICE) as UserManager + val boundActions: MutableSet = + Gesture.entries.mapNotNull { Action.forGesture(it) as? ShortcutAction }.map { it.shortcut } + .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) -> + launcherApps.pinShortcuts(p, + shortcuts.filter { boundActions.contains(PinnedShortcutInfo(it)) } + .map { it.id }.toList(), + profile + ) + } + } + } catch (_: SecurityException) { } } -fun openNewTabWindow(urls: String, context: Context) { - val uris = Uri.parse(urls) - val intents = Intent(Intent.ACTION_VIEW, uris) - val b = Bundle() - b.putBoolean("new_window", true) - intents.putExtras(b) - context.startActivity(intents) -} - -fun openAppSettings( - appInfo: AppInfo, - context: Context, - sourceBounds: Rect? = null, - opts: Bundle? = null -) { - val launcherApps = context.getSystemService(Service.LAUNCHER_APPS_SERVICE) as LauncherApps - appInfo.getLauncherActivityInfo(context)?.let { app -> - launcherApps.startAppDetailsActivity(app.componentName, app.user, sourceBounds, opts) +fun openInBrowser(url: String, context: Context) { + val intent = Intent(Intent.ACTION_VIEW, url.toUri()) + intent.putExtras(Bundle().apply { putBoolean("new_window", true) }) + try { + context.startActivity(intent) + } catch (_: ActivityNotFoundException) { + Toast.makeText(context, R.string.toast_activity_not_found_browser, Toast.LENGTH_LONG).show() } } @@ -153,24 +138,43 @@ fun openTutorial(context: Context) { /** - * [loadApps] is used to speed up the [AppsRecyclerAdapter] loading time, - * as it caches all the apps and allows for fast access to the data. + * Load all apps. */ -fun loadApps(packageManager: PackageManager, context: Context) { - 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 + val privateSpaceUser = getPrivateSpaceUser(context) + // TODO: shortcuts - launcherApps.getShortcuts() val users = userManager.userProfiles for (user in users) { + // don't load apps from a user profile that has quiet mode enabled + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + if (userManager.isQuietModeEnabled(user)) { + // hide paused apps + if (LauncherPreferences.apps().hidePausedApps()) { + continue + } + // hide apps from private space + if (isPrivateSpaceSupported() && + launcherApps.getLauncherUserInfo(user)?.userType == UserManager.USER_TYPE_PROFILE_PRIVATE + ) { + continue + } + } + } launcherApps.getActivityList(null, user).forEach { - loadList.add(DetailedAppInfo(it)) + loadList.add(DetailedAppInfo(it, it.user == privateSpaceUser)) } } - // fallback option if (loadList.isEmpty()) { Log.w(LOG_TAG, "using fallback option to load packages") @@ -178,40 +182,48 @@ fun loadApps(packageManager: PackageManager, context: Context) { i.addCategory(Intent.CATEGORY_LAUNCHER) val allApps = packageManager.queryIntentActivities(i, 0) for (ri in allApps) { - val app = AppInfo(ri.activityInfo.packageName, null, AppInfo.INVALID_USER) + val app = AppInfo(ri.activityInfo.packageName, null, INVALID_USER) val detailedAppInfo = DetailedAppInfo( app, ri.loadLabel(packageManager), - ri.activityInfo.loadIcon(packageManager) + ri.activityInfo.loadIcon(packageManager), + false ) loadList.add(detailedAppInfo) } } - loadList.sortBy { it.getCustomLabel(context).toString() } - appsList.clear() - appsList.addAll(loadList) + loadList.sortBy { it.getCustomLabel(context) } + + var end = System.currentTimeMillis() + Log.i(LOG_TAG, "${loadList.size} apps loaded (${end - start}ms)") + if (Build.VERSION.SDK_INT >= 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 """ + µLauncher version: ${BuildConfig.VERSION_NAME} (${BuildConfig.VERSION_CODE}) + Android version: ${Build.VERSION.RELEASE} (sdk ${Build.VERSION.SDK_INT}) + Model: ${Build.MODEL} + Device: ${Build.DEVICE} + Brand: ${Build.BRAND} + Manufacturer: ${Build.MANUFACTURER} + """.trimIndent() } -// Taken from https://stackoverflow.com/a/50743764/12787264 -fun openSoftKeyboard(context: Context, view: View) { - view.requestFocus() - // open the soft keyboard - val imm = context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager - imm.showSoftInput(view, InputMethodManager.SHOW_IMPLICIT) -} - -// Taken from: https://stackoverflow.com/a/30340794/12787264 -fun transformGrayscale(imageView: ImageView) { - val matrix = ColorMatrix() - matrix.setSaturation(0f) - - val filter = ColorMatrixColorFilter(matrix) - imageView.colorFilter = filter +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) } 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 0912207..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,84 +2,69 @@ 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.apps.AppInfo -import de.jrpie.android.launcher.apps.AppInfo.Companion.INVALID_USER -import de.jrpie.android.launcher.apps.DetailedAppInfo import de.jrpie.android.launcher.preferences.LauncherPreferences +import kotlinx.serialization.Serializable +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json -interface Action { + +/** + * 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 - fun bindToGesture(prefEditor: Editor, id: String) fun label(context: Context): String fun getIcon(context: Context): Drawable? fun isAvailable(context: Context): Boolean - fun writeToIntent(intent: Intent) + fun showConfigurationDialog(context: Context, onSuccess: (Action) -> Unit) { + onSuccess(this) + } + + // Can the action be used to reach µLauncher settings? + fun canReachSettings(): Boolean + + + fun bindToGesture(prefEditor: Editor, id: String) { + prefEditor.putString(id, Json.encodeToString(this)) + } companion object { - /** - * Get an action for a specific id. - * An id is of the form: - * - "launcher:${launcher_action_name}", see [LauncherAction] - * - "${package_name}", see [AppAction] - * - "${package_name}:${activity_name}", see [AppAction] - * - * @param id - * @param user a user id, ignored if the action is a [LauncherAction]. - * @param context used to complete [AppInfo] if possible - */ - private fun fromId(id: String, user: Int?, context: Context? = null): Action? { - if (id.isEmpty()) { - return null - } - if (LauncherAction.isOtherAction(id)) { - return LauncherAction.byId(id) - } - - val values = id.split(";") - - var info = AppInfo(values[0], values.getOrNull(1), user ?: INVALID_USER) - - // try to complete an incomplete AppInfo if a context is provided - if (context != null && (info.user == INVALID_USER || info.activityName == null)) { - info = DetailedAppInfo.fromAppInfo(info, context)?.app?:info - } - - return AppAction(info) - } fun forGesture(gesture: Gesture): Action? { val id = gesture.id val preferences = LauncherPreferences.getSharedPreferences() - val actionId = preferences.getString("$id.app", "")!! - var u: Int? = preferences.getInt("$id.user", INVALID_USER) - u = if (u == INVALID_USER) null else u - - return fromId(actionId, u) + val json = preferences.getString(id, "null")!! + return Json.decodeFromString(json) } 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, fromId(it, null, context)) } - .firstOrNull { it.second?.isAvailable(context) ?: false } - ?.apply { - boundActions.add(first) - second?.bindToGesture(editor, gesture.id) - } + 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) + } + } } - editor.apply() } fun setActionForGesture(gesture: Gesture, action: Action?) { @@ -87,16 +72,15 @@ 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() - .putString(gesture.id + ".app", "") - .putInt(gesture.id + ".user", INVALID_USER) - .apply() + LauncherPreferences.getSharedPreferences().edit { + remove(gesture.id) + } } fun launch( @@ -107,6 +91,9 @@ 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 { @@ -117,11 +104,5 @@ interface Action { ).show() } } - - fun fromIntent(data: Intent): Action? { - val value = data.getStringExtra("action_id") ?: return null - val user = data.getIntExtra("user", INVALID_USER) - return fromId(value, user) - } } } \ 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 741e19b..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 @@ -2,42 +2,47 @@ package de.jrpie.android.launcher.actions import android.app.AlertDialog import android.app.Service +import android.content.ActivityNotFoundException import android.content.Context import android.content.Intent -import android.content.SharedPreferences import android.content.pm.LauncherApps import android.graphics.Rect 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.getIntent -import de.jrpie.android.launcher.openAppSettings +import de.jrpie.android.launcher.ui.list.apps.openSettings +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable -class AppAction(val appInfo: AppInfo) : Action { +@Serializable +@SerialName("action:app") +class AppAction(val app: AppInfo) : Action { override fun invoke(context: Context, rect: Rect?): Boolean { - val packageName = appInfo.packageName.toString() - if (appInfo.user != INVALID_USER) { + val packageName = app.packageName + if (app.user != INVALID_USER) { val launcherApps = context.getSystemService(Service.LAUNCHER_APPS_SERVICE) as LauncherApps - appInfo.getLauncherActivityInfo(context)?.let { app -> - Log.i("Launcher", "Starting $appInfo") + app.getLauncherActivityInfo(context)?.let { app -> + Log.i("Launcher", "Starting ${this.app}") launcherApps.startMainActivity(app.componentName, app.user, rect, null) return true } } - val intent = getIntent(packageName, context) - - if (intent != null) { - context.startActivity(intent) + context.packageManager.getLaunchIntentForPackage(packageName)?.let { + it.addCategory(Intent.CATEGORY_LAUNCHER) + try { + context.startActivity(it) + } catch (_: ActivityNotFoundException) { + return false + } return true } - /* check if app is installed */ if (isAvailable(context)) { AlertDialog.Builder( @@ -47,7 +52,7 @@ class AppAction(val appInfo: AppInfo) : Action { .setTitle(context.getString(R.string.alert_cant_open_title)) .setMessage(context.getString(R.string.alert_cant_open_message)) .setPositiveButton(android.R.string.ok) { _, _ -> - openAppSettings(appInfo, context) + app.openSettings(context) } .setNegativeButton(android.R.string.cancel, null) .setIcon(android.R.drawable.ic_dialog_info) @@ -58,33 +63,19 @@ class AppAction(val appInfo: AppInfo) : Action { } override fun label(context: Context): String { - return DetailedAppInfo.fromAppInfo(appInfo, context)?.getCustomLabel(context).toString() + return DetailedAppInfo.fromAppInfo(app, context)?.getCustomLabel(context).toString() } override fun getIcon(context: Context): Drawable? { - return DetailedAppInfo.fromAppInfo(appInfo, context)?.icon + return DetailedAppInfo.fromAppInfo(app, context)?.getIcon(context) } override fun isAvailable(context: Context): Boolean { // check if app is installed - return DetailedAppInfo.fromAppInfo(appInfo, context) != null + return DetailedAppInfo.fromAppInfo(app, context) != null } - override fun bindToGesture(editor: SharedPreferences.Editor, id: String) { - val u = appInfo.user - - // TODO: replace this by AppInfo#serialize (breaking change to SharedPreferences!) - var app = appInfo.packageName.toString() - if (appInfo.activityName != null) { - app += ";${appInfo.activityName}" - } - editor - .putString("$id.app", app) - .putInt("$id.user", u) - } - - override fun writeToIntent(intent: Intent) { - intent.putExtra("action_id", "${appInfo.packageName};${appInfo.activityName}") - intent.putExtra("user", appInfo.user) + override fun canReachSettings(): Boolean { + return false } } \ No newline at end of file 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 e7358ba..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 @@ -1,6 +1,7 @@ package de.jrpie.android.launcher.actions import android.content.Context +import android.util.Log import de.jrpie.android.launcher.R import de.jrpie.android.launcher.preferences.LauncherPreferences @@ -78,6 +79,13 @@ enum class Gesture( R.array.default_up_right, R.anim.bottom_up ), + TAP_AND_SWIPE_UP( + "action.tap_up", + R.string.settings_gesture_tap_up, + R.string.settings_gesture_description_tap_up, + R.array.default_up, + R.anim.bottom_up + ), SWIPE_UP_DOUBLE( "action.double_up", R.string.settings_gesture_double_up, @@ -106,6 +114,13 @@ enum class Gesture( R.array.default_down_right, R.anim.top_down ), + TAP_AND_SWIPE_DOWN( + "action.tap_down", + R.string.settings_gesture_tap_down, + R.string.settings_gesture_description_tap_down, + R.array.default_down, + R.anim.bottom_up + ), SWIPE_DOWN_DOUBLE( "action.double_down", R.string.settings_gesture_double_down, @@ -134,6 +149,13 @@ enum class Gesture( R.array.default_messengers, R.anim.right_left ), + TAP_AND_SWIPE_LEFT( + "action.tap_left", + R.string.settings_gesture_tap_left, + R.string.settings_gesture_description_tap_left, + R.array.default_messengers, + R.anim.right_left + ), SWIPE_LEFT_DOUBLE( "action.double_left", R.string.settings_gesture_double_left, @@ -162,12 +184,73 @@ enum class Gesture( R.array.default_right_bottom, R.anim.left_right ), + TAP_AND_SWIPE_RIGHT( + "action.tap_right", + R.string.settings_gesture_tap_right, + R.string.settings_gesture_description_tap_right, + R.array.default_right, + R.anim.left_right + ), SWIPE_RIGHT_DOUBLE( "action.double_right", R.string.settings_gesture_double_right, R.string.settings_gesture_description_double_right, R.array.default_double_right, R.anim.left_right + ), + SWIPE_LARGER( + "action.larger", + R.string.settings_gesture_swipe_larger, + R.string.settings_gesture_description_swipe_larger, + R.array.no_default + ), + SWIPE_LARGER_REVERSE( + "action.larger_reverse", + R.string.settings_gesture_swipe_larger_reverse, + R.string.settings_gesture_description_swipe_larger_reverse, + R.array.no_default + ), + SWIPE_SMALLER( + "action.smaller", + R.string.settings_gesture_swipe_smaller, + R.string.settings_gesture_description_swipe_smaller, + R.array.no_default + ), + SWIPE_SMALLER_REVERSE( + "action.smaller_reverse", + R.string.settings_gesture_swipe_smaller_reverse, + R.string.settings_gesture_description_swipe_smaller_reverse, + R.array.no_default + ), + SWIPE_LAMBDA( + "action.lambda", + R.string.settings_gesture_swipe_lambda, + R.string.settings_gesture_description_swipe_lambda, + R.array.no_default + ), + SWIPE_LAMBDA_REVERSE( + "action.lambda_reverse", + R.string.settings_gesture_swipe_lambda_reverse, + R.string.settings_gesture_description_swipe_lambda_reverse, + R.array.no_default + ), + SWIPE_V( + "action.v", + R.string.settings_gesture_swipe_v, + R.string.settings_gesture_description_swipe_v, + R.array.no_default + ), + SWIPE_V_REVERSE( + "action.v_reverse", + R.string.settings_gesture_swipe_v_reverse, + R.string.settings_gesture_description_swipe_v_reverse, + R.array.no_default + ), + BACK( + "action.back", + R.string.settings_gesture_back, + R.string.settings_gesture_description_back, + R.array.default_back ); enum class Edge { @@ -224,6 +307,17 @@ enum class Gesture( } } + fun getTapComboVariant(): Gesture { + return when (this) { + SWIPE_UP -> TAP_AND_SWIPE_UP + SWIPE_DOWN -> TAP_AND_SWIPE_DOWN + SWIPE_LEFT -> TAP_AND_SWIPE_LEFT + SWIPE_RIGHT -> TAP_AND_SWIPE_RIGHT + else -> this + } + + } + fun isDoubleVariant(): Boolean { return when (this) { SWIPE_UP_DOUBLE, @@ -261,13 +355,14 @@ enum class Gesture( } operator fun invoke(context: Context) { + Log.i("Launcher", "Detected gesture: $this") val action = Action.forGesture(this) Action.launch(action, context, this.animationIn, this.animationOut) } companion object { fun byId(id: String): Gesture? { - return Gesture.values().firstOrNull { it.id == id } + return Gesture.entries.firstOrNull { it.id == id } } } 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 6c23bf0..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 @@ -2,7 +2,6 @@ package de.jrpie.android.launcher.actions import android.content.Context import android.content.Intent -import android.content.SharedPreferences.Editor import android.graphics.Rect import android.graphics.drawable.Drawable import android.media.AudioManager @@ -10,83 +9,158 @@ import android.os.Build import android.os.SystemClock 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.AppInfo.Companion.INVALID_USER +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 import de.jrpie.android.launcher.ui.list.ListActivity import de.jrpie.android.launcher.ui.settings.SettingsActivity +import kotlinx.serialization.KSerializer +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.SerializationException +import kotlinx.serialization.builtins.serializer +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.descriptors.buildClassSerialDescriptor +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder +import kotlinx.serialization.encoding.decodeStructure +import kotlinx.serialization.encoding.encodeStructure +@Serializable(with = LauncherActionSerializer::class) +@SerialName("action:launcher") enum class LauncherAction( val id: String, val label: Int, val icon: Int, - val launch: (Context) -> Unit + val launch: (Context) -> Unit, + private val canReachSettings: Boolean = false, + val available: (Context) -> Boolean = { true }, ) : Action { SETTINGS( - "launcher:settings", + "settings", R.string.list_other_settings, R.drawable.baseline_settings_24, - ::openSettings + ::openSettings, + true ), CHOOSE( - "launcher:choose", + "choose", R.string.list_other_list, R.drawable.baseline_menu_24, - ::openAppsList + ::openAppsList, + true ), CHOOSE_FROM_FAVORITES( - "launcher:chooseFromFavorites", + "choose_from_favorites", R.string.list_other_list_favorites, R.drawable.baseline_favorite_24, - { context -> openAppsList(context, true) } + { context -> openAppsList(context, favorite = true) }, + true + ), + CHOOSE_FROM_PRIVATE_SPACE( + "choose_from_private_space", + R.string.list_other_list_private_space, + R.drawable.baseline_security_24, + { context -> + if ((context.applicationContext as Application).privateSpaceLocked.value != true + || !hidePrivateSpaceWhenLocked(context) + ) { + openAppsList(context, private = true) + } + }, + available = { _ -> + isPrivateSpaceSupported() + } + ), + TOGGLE_PRIVATE_SPACE_LOCK( + "toggle_private_space_lock", + R.string.list_other_toggle_private_space_lock, + R.drawable.baseline_security_24, + ::togglePrivateSpaceLock, + available = { _ -> isPrivateSpaceSupported() } ), VOLUME_UP( - "launcher:volumeUp", + "volume_up", R.string.list_other_volume_up, - R.drawable.baseline_volume_up_24, ::audioVolumeUp + R.drawable.baseline_volume_up_24, + { context -> audioVolumeAdjust(context, AudioManager.ADJUST_RAISE) } ), VOLUME_DOWN( - "launcher:volumeDown", + "volume_down", R.string.list_other_volume_down, - R.drawable.baseline_volume_down_24, ::audioVolumeDown + R.drawable.baseline_volume_down_24, + { 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) } ), TRACK_NEXT( - "launcher:nextTrack", + "next_track", R.string.list_other_track_next, - R.drawable.baseline_skip_next_24, ::audioNextTrack + R.drawable.baseline_skip_next_24, + { context -> audioManagerPressKey(context, KeyEvent.KEYCODE_MEDIA_NEXT) } ), TRACK_PREV( - "launcher:previousTrack", + "previous_track", R.string.list_other_track_previous, - R.drawable.baseline_skip_previous_24, ::audioPreviousTrack + R.drawable.baseline_skip_previous_24, + { context -> audioManagerPressKey(context, KeyEvent.KEYCODE_MEDIA_PREVIOUS) } ), EXPAND_NOTIFICATIONS_PANEL( - "launcher:expandNotificationsPanel", + "expand_notifications_panel", R.string.list_other_expand_notifications_panel, R.drawable.baseline_notifications_24, ::expandNotificationsPanel ), EXPAND_SETTINGS_PANEL( - "launcher:expandSettingsPanel", + "expand_settings_panel", R.string.list_other_expand_settings_panel, 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( - "launcher:lockScreen", + "lock_screen", R.string.list_other_lock_screen, - R.drawable.baseline_lock_24px, + R.drawable.baseline_lock_24, { c -> LauncherPreferences.actions().lockMethod().lockOrEnable(c) } ), TORCH( - "launcher:toggleTorch", + "toggle_torch", R.string.list_other_torch, R.drawable.baseline_flashlight_on_24, - ::toggleTorch + ::toggleTorch, ), - NOP("launcher:nop", R.string.list_other_nop, R.drawable.baseline_not_interested_24, {}); + 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, {}); override fun invoke(context: Context, rect: Rect?): Boolean { launch(context) @@ -98,86 +172,48 @@ enum class LauncherAction( } override fun getIcon(context: Context): Drawable? { - return context.getDrawable(icon) - } - - override fun bindToGesture(editor: Editor, id: String) { - editor - .putString("$id.app", this.id) - .putInt("$id.user", INVALID_USER) - } - - override fun writeToIntent(intent: Intent) { - intent.putExtra("action_id", id) + return AppCompatResources.getDrawable(context, icon) } override fun isAvailable(context: Context): Boolean { - return true + return this.available(context) + } + + override fun canReachSettings(): Boolean { + return this.canReachSettings } companion object { fun byId(id: String): LauncherAction? { return entries.singleOrNull { it.id == id } } - - fun isOtherAction(id: String): Boolean { - return id.startsWith("launcher") - } } } /* Media player actions */ - -private fun audioNextTrack(context: Context) { - +private fun audioManagerPressKey(context: Context, key: Int) { val mAudioManager = context.getSystemService(Context.AUDIO_SERVICE) as AudioManager - val eventTime: Long = SystemClock.uptimeMillis() - val downEvent = - KeyEvent(eventTime, eventTime, KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_MEDIA_NEXT, 0) + KeyEvent(eventTime, eventTime, KeyEvent.ACTION_DOWN, key, 0) mAudioManager.dispatchMediaKeyEvent(downEvent) - - val upEvent = KeyEvent(eventTime, eventTime, KeyEvent.ACTION_UP, KeyEvent.KEYCODE_MEDIA_NEXT, 0) + val upEvent = KeyEvent(eventTime, eventTime, KeyEvent.ACTION_UP, key, 0) mAudioManager.dispatchMediaKeyEvent(upEvent) + } -private fun audioPreviousTrack(context: Context) { - val mAudioManager = context.getSystemService(Context.AUDIO_SERVICE) as AudioManager - - val eventTime: Long = SystemClock.uptimeMillis() - - val downEvent = - KeyEvent(eventTime, eventTime, KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_MEDIA_PREVIOUS, 0) - mAudioManager.dispatchMediaKeyEvent(downEvent) - - val upEvent = - KeyEvent(eventTime, eventTime, KeyEvent.ACTION_UP, KeyEvent.KEYCODE_MEDIA_PREVIOUS, 0) - mAudioManager.dispatchMediaKeyEvent(upEvent) -} - -private fun audioVolumeUp(context: Context) { +private fun audioVolumeAdjust(context: Context, direction: Int) { val audioManager = context.getSystemService(Context.AUDIO_SERVICE) as AudioManager audioManager.adjustStreamVolume( AudioManager.STREAM_MUSIC, - AudioManager.ADJUST_RAISE, + direction, AudioManager.FLAG_SHOW_UI ) } -private fun audioVolumeDown(context: Context) { - val audioManager = - context.getSystemService(Context.AUDIO_SERVICE) as AudioManager - - audioManager.adjustStreamVolume( - AudioManager.STREAM_MUSIC, - AudioManager.ADJUST_LOWER, - AudioManager.FLAG_SHOW_UI - ) -} /* End media player actions */ private fun toggleTorch(context: Context) { @@ -210,6 +246,7 @@ private fun expandNotificationsPanel(context: Context) { } } + private fun expandSettingsPanel(context: Context) { /* https://stackoverflow.com/a/31898506 */ try { @@ -227,11 +264,25 @@ 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)) } -fun openAppsList(context: Context, favorite: Boolean = false, hidden: Boolean = false) { +fun openAppsList( + context: Context, + favorite: Boolean = false, + hidden: Boolean = false, + private: Boolean = false +) { val intent = Intent(context, ListActivity::class.java) intent.putExtra("intention", ListActivity.ListActivityIntention.VIEW.toString()) intent.putExtra( @@ -250,6 +301,41 @@ fun openAppsList(context: Context, favorite: Boolean = false, hidden: Boolean = AppFilter.Companion.AppSetVisibility.HIDDEN } ) + intent.putExtra( + "privateSpaceVisibility", + if (private) { + AppFilter.Companion.AppSetVisibility.EXCLUSIVE + } else if (!hidden && LauncherPreferences.apps().hidePrivateSpaceApps()) { + AppFilter.Companion.AppSetVisibility.HIDDEN + } else { + AppFilter.Companion.AppSetVisibility.VISIBLE + } + ) context.startActivity(intent) +} + +/* A custom serializer is required to store type information, + see https://github.com/Kotlin/kotlinx.serialization/issues/1486 + */ +private class LauncherActionSerializer : KSerializer { + override val descriptor: SerialDescriptor = buildClassSerialDescriptor( + "action:launcher", + ) { + element("value", String.serializer().descriptor) + } + + override fun deserialize(decoder: Decoder): LauncherAction { + val s = decoder.decodeStructure(descriptor) { + decodeElementIndex(descriptor) + decodeSerializableElement(descriptor, 0, String.serializer()) + } + return LauncherAction.byId(s) ?: throw SerializationException() + } + + override fun serialize(encoder: Encoder, value: LauncherAction) { + encoder.encodeStructure(descriptor) { + encodeSerializableElement(descriptor, 0, String.serializer(), value.id) + } + } } \ No newline at end of file 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 new file mode 100644 index 0000000..a89f9e2 --- /dev/null +++ b/app/src/main/java/de/jrpie/android/launcher/actions/ShortcutAction.kt @@ -0,0 +1,57 @@ +package de.jrpie.android.launcher.actions + +import android.app.Service +import android.content.Context +import android.content.pm.LauncherApps +import android.graphics.Rect +import android.graphics.drawable.Drawable +import android.os.Build +import de.jrpie.android.launcher.apps.PinnedShortcutInfo +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +@SerialName("action:shortcut") +class ShortcutAction(val shortcut: PinnedShortcutInfo) : Action { + + override fun invoke(context: Context, rect: Rect?): Boolean { + val launcherApps = context.getSystemService(Service.LAUNCHER_APPS_SERVICE) as LauncherApps + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N_MR1) { + // TODO + return false + } + shortcut.getShortcutInfo(context)?.let { + launcherApps.startShortcut(it, rect, null) + } + + // TODO: handle null + return true + } + + override fun label(context: Context): String { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N_MR1) { + return "?" + } + + return shortcut.getShortcutInfo(context)?.longLabel?.toString() ?: "?" + } + + override fun getIcon(context: Context): Drawable? { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N_MR1) { + return null + } + val launcherApps = context.getSystemService(Service.LAUNCHER_APPS_SERVICE) as LauncherApps + return shortcut.getShortcutInfo(context)?.let { launcherApps.getShortcutBadgedIconDrawable(it, 0) } + } + + override fun isAvailable(context: Context): Boolean { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N_MR1) { + return false + } + return shortcut.getShortcutInfo(context) != null + } + + override fun canReachSettings(): Boolean { + return false + } +} \ No newline at end of file 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 9e018d1..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,9 +76,9 @@ 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_consent_accessibility_cancel) { _, _ -> } + setNegativeButton(R.string.dialog_cancel) { _, _ -> } }.create().also { it.show() }.apply { val buttonOk = getButton(AlertDialog.BUTTON_POSITIVE) val checkboxes = listOf( @@ -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 16572e5..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 @@ -2,13 +2,14 @@ package de.jrpie.android.launcher.actions.lock import android.content.Context import android.os.Build -import android.view.LayoutInflater +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, @@ -36,32 +37,28 @@ enum class LockMethod( companion object { fun chooseMethod(context: Context) { - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.P) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.P || + ! BuildConfig.USE_ACCESSIBILITY_SERVICE) { // only device admin is available setMethod(context, DEVICE_ADMIN) return } - val builder = AlertDialog.Builder(context, R.style.AlertDialogCustom) - builder.setNegativeButton("cancel") { _, _ -> } - builder.setCustomTitle( - LayoutInflater.from(context).inflate(R.layout.dialog_select_lock_method, null) - ) - - builder.setItems( - arrayOf( - context.getString(R.string.screen_lock_method_use_accessibility), - context.getString(R.string.screen_lock_method_use_device_admin) - ) - ) { _, i -> - val method = when (i) { - 0 -> ACCESSIBILITY_SERVICE - 1 -> DEVICE_ADMIN - else -> return@setItems + AlertDialog.Builder(context, R.style.AlertDialogCustom).apply { + setNegativeButton(R.string.dialog_cancel) { _, _ -> } + setView(R.layout.dialog_select_lock_method) + // setTitle() + }.create().also { it.show() }.apply { + findViewById