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 f580315..0000000 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ /dev/null @@ -1,31 +0,0 @@ ---- -name: Bug report -about: Create a report to help improve this app -title: '' -labels: bug -assignees: '' - ---- - -**Describe the bug** -A clear and concise description of what the bug is. - -**To Reproduce** -Steps to reproduce the behavior: -1. Go to '...' -2. Click on '....' -3. Scroll down to '....' -4. See error - -**Expected behavior** -A clear and concise description of what you expected to happen. - -**Screenshots** -If applicable, add screenshots to help explain your problem. - -**Smartphone (please complete the following information):** - - Device: [e.g. Samsung A7] - - Android Version: [e.g. Marshmallow, 6.0 or API 23] - -**Additional info** -Add any other info or comments about the problem here. 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/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md index e2b3f9d..1a4f00c 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.md +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -1,20 +1,24 @@ --- name: Feature request about: Suggest an idea for this project -title: '' +title: '[feature] ' labels: enhancement assignees: '' --- -**Is your feature request related to a problem? Please describe.** -A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] +# Please describe the problem to be solved -**Describe the solution you'd like** -A clear and concise description of what you want to happen. + -**Describe alternatives you've considered** -A clear and concise description of any alternative solutions or features you've considered. +# Describe the solution you would suggest -**Additional info** -Add any other info, comments or screenshots about the feature request here. + + +# Describe alternative solutions you've considered + + + +# Additional info + + diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml new file mode 100644 index 0000000..ba9a709 --- /dev/null +++ b/.github/workflows/android.yml @@ -0,0 +1,32 @@ +name: Android CI + +on: + push: + branches: [ "master" ] + pull_request: + branches: [ "master" ] + +jobs: + build: + + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + - name: set up JDK 17 + uses: actions/setup-java@v4 + with: + java-version: '17' + distribution: 'temurin' + cache: gradle + + - name: Grant execute permission for gradlew + run: chmod +x gradlew + - name: Build with Gradle + run: ./gradlew build + + - name: upload apk + uses: actions/upload-artifact@v4 + with: + name: launcher-debug-${{ github.sha }}.apk + path: app/build/outputs/apk/default/debug/app-default-debug.apk diff --git a/.gitignore b/.gitignore index 56cc642..c21100e 100644 --- a/.gitignore +++ b/.gitignore @@ -15,7 +15,9 @@ bin/ gen/ out/ # Uncomment the following line in case you need and you don't have the release build type files in your app -# release/ +release/ +app/release/ +.kotlin/ # Gradle files .gradle/ @@ -38,12 +40,15 @@ captures/ # IntelliJ *.iml +.idea/* .idea/workspace.xml .idea/tasks.xml +.idea/other.xml .idea/gradle.xml .idea/assetWizardSettings.xml .idea/dictionaries .idea/libraries +.idea/deploymentTargetSelector.xml # Android Studio 3 in .gitignore file. .idea/caches .idea/modules.xml diff --git a/.idea/codeStyles/Project.xml b/.idea/codeStyles/Project.xml deleted file mode 100644 index 3cc336b..0000000 --- a/.idea/codeStyles/Project.xml +++ /dev/null @@ -1,138 +0,0 @@ - - - - - - - - - - - -
- - - - xmlns:android - - ^$ - - - -
-
- - - - xmlns:.* - - ^$ - - - BY_NAME - -
-
- - - - .*:id - - http://schemas.android.com/apk/res/android - - - -
-
- - - - .*:name - - http://schemas.android.com/apk/res/android - - - -
-
- - - - name - - ^$ - - - -
-
- - - - style - - ^$ - - - -
-
- - - - .* - - ^$ - - - BY_NAME - -
-
- - - - .* - - http://schemas.android.com/apk/res/android - - - ANDROID_ATTRIBUTE_ORDER - -
-
- - - - .* - - .* - - - BY_NAME - -
-
-
-
- - -
-
\ No newline at end of file diff --git a/.idea/codeStyles/codeStyleConfig.xml b/.idea/codeStyles/codeStyleConfig.xml deleted file mode 100644 index 79ee123..0000000 --- a/.idea/codeStyles/codeStyleConfig.xml +++ /dev/null @@ -1,5 +0,0 @@ - - - - \ No newline at end of file diff --git a/.idea/compiler.xml b/.idea/compiler.xml deleted file mode 100644 index b589d56..0000000 --- a/.idea/compiler.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/.idea/deploymentTargetSelector.xml b/.idea/deploymentTargetSelector.xml deleted file mode 100644 index b268ef3..0000000 --- a/.idea/deploymentTargetSelector.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - - - - - - - \ No newline at end of file diff --git a/.idea/jarRepositories.xml b/.idea/jarRepositories.xml deleted file mode 100644 index a5f05cd..0000000 --- a/.idea/jarRepositories.xml +++ /dev/null @@ -1,25 +0,0 @@ - - - - - - - - - - - - - \ No newline at end of file diff --git a/.idea/kotlinc.xml b/.idea/kotlinc.xml deleted file mode 100644 index 7e340a7..0000000 --- a/.idea/kotlinc.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml deleted file mode 100644 index 97021b9..0000000 --- a/.idea/misc.xml +++ /dev/null @@ -1,17 +0,0 @@ - - - - - - - - - - \ No newline at end of file diff --git a/.idea/other.xml b/.idea/other.xml deleted file mode 100644 index 0d3a1fb..0000000 --- a/.idea/other.xml +++ /dev/null @@ -1,263 +0,0 @@ - - - - - - \ No newline at end of file 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 2e25e3e..ce1d0d0 100644 --- a/README.md +++ b/README.md @@ -1,38 +1,112 @@ - +[![][shield-release]][latest-release] +[![Android CI](https://github.com/jrpie/Launcher/actions/workflows/android.yml/badge.svg)](https://github.com/jrpie/Launcher/actions/workflows/android.yml) [![][shield-license]][license] - - - -# Launcher - -This is a fork of [finnmglas's app Launcher][original-repo]. - -## Notable changes: - -* Edge gestures: There is a setting to allow distinguishing swiping at the edges of the screen from swiping in the center. - -### 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. +[![Chat on Matrix](https://matrix.to/img/matrix-badge.svg)][matrix] +[![Chat on Discord](https://img.shields.io/badge/discord-join%20chat-007ec6.svg?style=flat)][discord] -### 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 ---- + +# μLauncher + + +µ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*. + + +Get it on F-Droid +Get it on Accrescent +Get it on Obtainium +Get it on GitHub + +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. + +screenshot + screenshot + screenshot + screenshot + screenshot + screenshot + screenshot + + +µ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: +* You can add or improve [translations][toolate]. +
translation status +* 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/` or `fix/` and commit your changes. + - Open a new pull request. + + +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. + --- [hack-font]: https://sourcefoundry.org/hack/ [original-repo]: https://github.com/finnmglas/Launcher + [toolate]: https://toolate.othing.xyz/projects/jrpie-launcher/ + [issues]: https://github.com/jrpie/Launcher/issues/ + [fork]: https://github.com/jrpie/Launcher/fork/ @@ -44,14 +118,16 @@ This is a fork of [finnmglas's app Launcher][original-repo]. [shield-release]: https://img.shields.io/github/v/release/jrpie/Launcher?style=flat + [latest-release]: https://github.com/jrpie/Launcher/releases/latest [shield-contribute]: https://img.shields.io/badge/contributions-welcome-007ec6.svg?style=flat [shield-license]: https://img.shields.io/badge/license-MIT-007ec6?style=flat [shield-gh-watch]: https://img.shields.io/github/watchers/jrpie/Launcher?label=Watch&style=social [shield-gh-star]: https://img.shields.io/github/stars/jrpie/Launcher?label=Star&style=social [shield-gh-fork]: https://img.shields.io/github/forks/jrpie/Launcher?label=Fork&style=social - - + [matrix]: https://s.jrpie.de/launcher-matrix + [discord]: https://s.jrpie.de/launcher-discord + [chat]: https://s.jrpie.de/launcher-chat diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..7e3e763 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,5 @@ +# Reporting Security Issues + +For security related issues, please use the contact information +from the [security.txt](https://jrpie.de/.well-known/security.txt) on my website +or [report a vulnerability](https://github.com/jrpie/Launcher/security/advisories/new) on github. diff --git a/app/build.gradle b/app/build.gradle index 5f7bdc6..1a0a6fb 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -1,45 +1,115 @@ apply plugin: 'com.android.application' apply plugin: 'kotlin-android' -apply plugin: 'kotlin-android-extensions' +apply plugin: 'kotlin-kapt' +apply plugin: 'kotlinx-serialization' android { - compileSdkVersion 34 - buildToolsVersion "34.0.0" + dataBinding { + enabled = true + } + + packaging { + resources.excludes.addAll( + [ + "META-INF/LICENSE.md", + "META-INF/NOTICE.md", + "META-INF/LICENSE-notice.md" + ] + ) + } defaultConfig { applicationId "de.jrpie.android.launcher" minSdkVersion 21 targetSdkVersion 35 - versionCode 16 - versionName "j-0.0.4" + compileSdk 35 + versionCode 44 + versionName "0.1.4" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" } + compileOptions { + sourceCompatibility JavaVersion.VERSION_17 + targetCompatibility JavaVersion.VERSION_17 + } + kotlinOptions { + jvmTarget = '17' + } + + buildFeatures { + viewBinding true + } + buildTypes { release { 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 } + dependenciesInfo { + // Disables dependency metadata when building APKs. + includeInApk = false + // Disables dependency metadata when building Android App Bundles. + includeInBundle = false + } + lint { + abortOnError false + } + + } dependencies { implementation fileTree(dir: 'libs', include: ['*.jar']) implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version" - implementation 'androidx.appcompat:appcompat:1.1.0' - implementation 'androidx.core:core-ktx:1.2.0' - implementation 'androidx.constraintlayout:constraintlayout:1.1.3' - implementation 'androidx.legacy:legacy-support-v4:1.0.0' - testImplementation 'junit:junit:4.12' - androidTestImplementation 'androidx.test.ext:junit:1.1.1' - androidTestImplementation 'androidx.test.espresso:espresso-core:3.2.0' - implementation 'com.google.android.material:material:1.1.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' implementation 'androidx.gridlayout:gridlayout:1.0.0' implementation 'androidx.palette:palette-ktx:1.0.0' + 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" + annotationProcessor "eu.jonahbauer:android-preference-annotations:1.1.2" + annotationProcessor "com.android.databinding:compiler:$android_plugin_version" + testImplementation 'junit:junit:4.13.2' + androidTestImplementation 'androidx.test.ext:junit:1.2.1' + androidTestImplementation 'androidx.test.espresso:espresso-core:3.6.1' } + diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro index f1b4245..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. # @@ -19,3 +21,10 @@ # If you keep the line number information, uncomment this to # hide the original source file name. #-renamesourcefileattribute SourceFile + +# see app/build/outputs/mapping/release/missing_rules.txt +# Please add these rules to your existing keep rules in order to suppress warnings. +# 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/release/output-metadata.json b/app/release/output-metadata.json deleted file mode 100644 index 7f5bd2d..0000000 --- a/app/release/output-metadata.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "version": 3, - "artifactType": { - "type": "APK", - "kind": "Directory" - }, - "applicationId": "de.jrpie.android.launcher", - "variantName": "release", - "elements": [ - { - "type": "SINGLE", - "filters": [], - "attributes": [], - "versionCode": 16, - "versionName": "j-0.0.4", - "outputFile": "app-release.apk" - } - ], - "elementType": "File", - "minSdkVersionForDexing": 21 -} \ No newline at end of file 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 30baf2c..a5f8831 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -3,43 +3,98 @@ xmlns:tools="http://schemas.android.com/tools"> - - - + + - - + + + + + + + + android:launchMode="singleTask" + android:theme="@style/launcherHomeTheme"> + - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + - \ No newline at end of file + diff --git a/app/src/main/ic_launcher-playstore.png b/app/src/main/ic_launcher-playstore.png index f3bf62c..a5bfb80 100644 Binary files a/app/src/main/ic_launcher-playstore.png and b/app/src/main/ic_launcher-playstore.png differ diff --git a/app/src/main/java/de/jrpie/android/launcher/Application.kt b/app/src/main/java/de/jrpie/android/launcher/Application.kt new file mode 100644 index 0000000..e6cce23 --- /dev/null +++ b/app/src/main/java/de/jrpie/android/launcher/Application.kt @@ -0,0 +1,160 @@ +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 androidx.preference.PreferenceManager +import de.jrpie.android.launcher.actions.TorchManager +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 kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch + +class Application : android.app.Application() { + val apps = MutableLiveData>() + val privateSpaceLocked = MutableLiveData() + + 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 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() + } + } + + override fun onCreate() { + super.onCreate() + // TODO Error: Invalid resource ID 0x00000000. + // DynamicColors.applyToActivitiesIfAvailable(this) + + + if (Build.VERSION.SDK_INT >= VERSION_CODES.M) { + torchManager = TorchManager(this) + } + + 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 { + 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)) + } + } +} 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 72f2075..afc2c31 100644 --- a/app/src/main/java/de/jrpie/android/launcher/Functions.kt +++ b/app/src/main/java/de/jrpie/android/launcher/Functions.kt @@ -1,418 +1,226 @@ package de.jrpie.android.launcher import android.app.Activity -import android.app.AlertDialog +import android.app.Service +import android.app.role.RoleManager +import android.content.ActivityNotFoundException +import android.content.ClipData +import android.content.ClipboardManager import android.content.Context import android.content.Intent -import android.content.SharedPreferences +import android.content.pm.LauncherApps +import android.content.pm.LauncherApps.ShortcutQuery import android.content.pm.PackageManager -import android.graphics.BlendMode -import android.graphics.BlendModeColorFilter -import android.graphics.ColorMatrix -import android.graphics.ColorMatrixColorFilter -import android.graphics.PorterDuff -import android.graphics.PorterDuffColorFilter -import android.media.AudioManager -import android.net.Uri +import android.content.pm.ShortcutInfo import android.os.Build import android.os.Bundle -import android.os.SystemClock +import android.os.UserHandle +import android.os.UserManager import android.provider.Settings -import android.util.DisplayMetrics -import android.view.KeyEvent -import android.view.View -import android.view.Window -import android.view.WindowManager -import android.view.animation.AlphaAnimation -import android.view.animation.Animation -import android.view.inputmethod.InputMethodManager -import android.widget.Button -import android.widget.ImageView -import android.widget.Switch +import android.util.Log import android.widget.Toast -import de.jrpie.android.launcher.list.ListActivity -import de.jrpie.android.launcher.list.apps.AppInfo -import de.jrpie.android.launcher.list.apps.AppsRecyclerAdapter -import de.jrpie.android.launcher.list.other.LauncherAction -import de.jrpie.android.launcher.settings.SettingsActivity -import de.jrpie.android.launcher.settings.intendedSettingsPause -import de.jrpie.android.launcher.tutorial.TutorialActivity +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.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 -/* Preference Key Constants */ +const val LOG_TAG = "Launcher" -const val PREF_DOMINANT = "custom_dominant" -const val PREF_VIBRANT = "custom_vibrant" -const val PREF_THEME = "theme" +const val REQUEST_SET_DEFAULT_HOME = 42 -const val PREF_SCREEN_TIMEOUT_DISABLED = "disableTimeout" -const val PREF_SCREEN_FULLSCREEN = "useFullScreen" -const val PREF_DATE_FORMAT = "dateFormat" - -const val PREF_DOUBLE_ACTIONS_ENABLED = "enableDoubleActions" -const val PREF_EDGE_ACTIONS_ENABLED = "enableEdgeActions" -const val PREF_SEARCH_AUTO_LAUNCH = "searchAutoLaunch" -const val PREF_SEARCH_AUTO_KEYBOARD = "searchAutoKeyboard" - -const val PREF_SLIDE_SENSITIVITY = "slideSensitivity" - -const val PREF_STARTED = "startedBefore" -const val PREF_STARTED_TIME = "firstStartup" - -const val PREF_VERSION = "version" - -/* Objects used by multiple activities */ -val appsList: MutableList = ArrayList() - -/* Variables containing settings */ -val displayMetrics = DisplayMetrics() - -var dominantColor = 0 -var vibrantColor = 0 - -/* REQUEST CODES */ - -const val REQUEST_CHOOSE_APP = 1 -const val REQUEST_UNINSTALL = 2 - -/* Animate */ - -// 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 getPreferences(context: Context): SharedPreferences{ - return context.getSharedPreferences( - context.getString(R.string.preference_file_key), - Context.MODE_PRIVATE - ) -} - -/* Activity related */ - -fun isInstalled(uri: String, context: Context): Boolean { - if (uri.startsWith("launcher:")) return true // All internal actions - - try { - context.packageManager.getPackageInfo(uri, PackageManager.GET_ACTIVITIES) - return true - } catch (_: PackageManager.NameNotFoundException) { } - return false -} - -private fun getIntent(packageName: String, context: Context): Intent? { - val intent: Intent? = context.packageManager.getLaunchIntentForPackage(packageName) - intent?.addCategory(Intent.CATEGORY_LAUNCHER) - return intent -} - -fun launch( - data: String, activity: Activity, - animationIn: Int = android.R.anim.fade_in, animationOut: Int = android.R.anim.fade_out -) { - - if (LauncherAction.isOtherAction(data)) { // [type]:[info] - LauncherAction.byId(data)?.let {it.launch(activity) } - } - else launchApp(data, activity) // app - - activity.overridePendingTransition(animationIn, animationOut) -} - -/* Media player actions */ - -fun audioNextTrack(activity: Activity) { - - val mAudioManager = activity.getSystemService(Context.AUDIO_SERVICE) as AudioManager - - val eventTime: Long = SystemClock.uptimeMillis() - - val downEvent = KeyEvent(eventTime, eventTime, KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_MEDIA_NEXT, 0) - mAudioManager.dispatchMediaKeyEvent(downEvent) - - val upEvent = KeyEvent(eventTime, eventTime, KeyEvent.ACTION_UP, KeyEvent.KEYCODE_MEDIA_NEXT, 0) - mAudioManager.dispatchMediaKeyEvent(upEvent) -} - -fun audioPreviousTrack(activity: Activity) { - val mAudioManager = activity.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) -} - -fun audioVolumeUp(activity: Activity) { - val audioManager = - activity.getSystemService(Context.AUDIO_SERVICE) as AudioManager - - audioManager.adjustStreamVolume( - AudioManager.STREAM_MUSIC, - AudioManager.ADJUST_RAISE, - AudioManager.FLAG_SHOW_UI - ) -} - -fun audioVolumeDown(activity: Activity) { - val audioManager = - activity.getSystemService(Context.AUDIO_SERVICE) as AudioManager - - audioManager.adjustStreamVolume( - AudioManager.STREAM_MUSIC, - AudioManager.ADJUST_LOWER, - AudioManager.FLAG_SHOW_UI - ) -} - -/* --- */ - -fun launchApp(packageName: String, context: Context) { - val intent = getIntent(packageName, context) - - if (intent != null) { - context.startActivity(intent) +fun isDefaultHomeScreen(context: Context): Boolean { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + val roleManager = context.getSystemService(RoleManager::class.java) + return roleManager.isRoleHeld(RoleManager.ROLE_HOME) } else { - if (isInstalled(packageName, context)){ - - AlertDialog.Builder( - context, - R.style.AlertDialogCustom - ) - .setTitle(context.getString(R.string.alert_cant_open_title)) - .setMessage(context.getString(R.string.alert_cant_open_message)) - .setPositiveButton(android.R.string.ok - ) { _, _ -> - openAppSettings( - packageName, - context - ) - } - .setNegativeButton(android.R.string.cancel, null) - .setIcon(android.R.drawable.ic_dialog_info) - .show() - } else { - Toast.makeText( - context, - context.getString(R.string.toast_cant_open_message), - Toast.LENGTH_SHORT - ).show() - } + val testIntent = Intent(Intent.ACTION_MAIN) + testIntent.addCategory(Intent.CATEGORY_HOME) + val defaultHome = testIntent.resolveActivity(context.packageManager)?.packageName + return defaultHome == context.packageName } } -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 setDefaultHomeScreen(context: Context, checkDefault: Boolean = false) { + val isDefault = isDefaultHomeScreen(context) + if (checkDefault && isDefault) { + // Launcher is already the default home app + return + } -/* Settings related functions */ + 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 + } -fun getSavedTheme(context: Context) : String { - return getPreferences(context).getString(PREF_THEME, "finn").toString() -} - -fun saveTheme(context: Context, themeName: String) : String { - getPreferences(context).edit() - .putString(PREF_THEME, themeName) - .apply() - - return themeName -} - -fun resetToDefaultTheme(activity: Activity) { - dominantColor = activity.resources.getColor(R.color.finnmglasTheme_background_color) - vibrantColor = activity.resources.getColor(R.color.finnmglasTheme_accent_color) - - getPreferences(activity).edit() - .putInt(PREF_DOMINANT, dominantColor) - .putInt(PREF_VIBRANT, vibrantColor) - .apply() - - saveTheme(activity,"finn") - loadSettings(activity) - - intendedSettingsPause = true - activity.recreate() -} - -fun resetToDarkTheme(activity: Activity) { - dominantColor = activity.resources.getColor(R.color.darkTheme_background_color) - vibrantColor = activity.resources.getColor(R.color.darkTheme_accent_color) - - getPreferences(activity).edit() - .putInt(PREF_DOMINANT, dominantColor) - .putInt(PREF_VIBRANT, vibrantColor) - .apply() - - saveTheme(activity,"dark") - - intendedSettingsPause = true - activity.recreate() -} - - -fun openAppSettings(pkg: String, context: Context) { - val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS) - intent.data = Uri.parse("package:$pkg") + val intent = Intent(Settings.ACTION_HOME_SETTINGS) context.startActivity(intent) } -fun openSettings(activity: Activity) { - activity.startActivity(Intent(activity, SettingsActivity::class.java)) +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() == userId } ?: profiles[0] } -fun openTutorial(activity: Activity){ - activity.startActivity(Intent(activity, TutorialActivity::class.java)) +@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 + } + } + + 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 openAppsList(activity: Activity){ - val intent = Intent(activity, ListActivity::class.java) - intent.putExtra("intention", ListActivity.ListActivityIntention.VIEW.toString()) - intendedSettingsPause = true - activity.startActivity(intent) +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() + } } +fun openTutorial(context: Context) { + context.startActivity(Intent(context, TutorialActivity::class.java)) +} + + /** - * [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) { - val loadList = mutableListOf() +fun getApps( + packageManager: PackageManager, + context: Context +): MutableList { + var start = System.currentTimeMillis() + val loadList = mutableListOf() - val i = Intent(Intent.ACTION_MAIN, null) - i.addCategory(Intent.CATEGORY_LAUNCHER) - val allApps = packageManager.queryIntentActivities(i, 0) - for (ri in allApps) { - val app = AppInfo() - app.label = ri.loadLabel(packageManager) - app.packageName = ri.activityInfo.packageName - app.icon = ri.activityInfo.loadIcon(packageManager) - loadList.add(app) + 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, it.user == privateSpaceUser)) + } } - loadList.sortBy { it.label.toString() } - appsList.clear() - appsList.addAll(loadList) -} - -fun loadSettings(context: Context) { - val preferences = getPreferences(context) - dominantColor = preferences.getInt(PREF_DOMINANT, 0) - vibrantColor = preferences.getInt(PREF_VIBRANT, 0) -} - -fun resetSettings(context: Context) { - - val editor = getPreferences(context).edit() - - // set default theme - dominantColor = context.resources.getColor(R.color.finnmglasTheme_background_color) - vibrantColor = context.resources.getColor(R.color.finnmglasTheme_accent_color) - - editor - .putInt(PREF_DOMINANT, dominantColor) - .putInt(PREF_VIBRANT, vibrantColor) - .putString(PREF_THEME, "finn") - .putBoolean(PREF_SCREEN_TIMEOUT_DISABLED, false) - .putBoolean(PREF_SEARCH_AUTO_LAUNCH, false) - .putInt(PREF_DATE_FORMAT, 0) - .putBoolean(PREF_SCREEN_FULLSCREEN, true) - .putBoolean(PREF_DOUBLE_ACTIONS_ENABLED, false) - .putInt(PREF_SLIDE_SENSITIVITY, 50) - - Gesture.values().forEach { editor.putString(it.id, it.pickDefaultApp(context)) } - - editor.apply() -} - -fun setWindowFlags(window: Window) { - window.setFlags(0, 0) // clear flags - - val preferences = getPreferences(window.context) - // Display notification bar - if (preferences.getBoolean(PREF_SCREEN_FULLSCREEN, true)) - window.setFlags( - WindowManager.LayoutParams.FLAG_FULLSCREEN, - WindowManager.LayoutParams.FLAG_FULLSCREEN - ) - else window.clearFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN) - - // Screen Timeout - if (preferences.getBoolean(PREF_SCREEN_TIMEOUT_DISABLED, false)) - window.setFlags( - WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON, - WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON - ) - else window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) -} - -// Used in Tutorial and Settings `ActivityOnResult` -fun saveListActivityChoice(context: Context, data: Intent?) { - val value = data?.getStringExtra("value") - val forGesture = data?.getStringExtra("forGesture") ?: return - - Gesture.byId(forGesture)?.setApp(context, value.toString()) - - loadSettings(context) -} - -// 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) -} - -/* Bitmaps */ - -fun setButtonColor(btn: Button, color: Int) { - if (Build.VERSION.SDK_INT >= 29) - btn.background.colorFilter = BlendModeColorFilter(color, BlendMode.MULTIPLY) - else { - // tested with API 17 (Android 4.4.2 on S4 mini) -> fails - // tested with API 28 (Android 9 on S8) -> necessary - btn.background.colorFilter = PorterDuffColorFilter(color, PorterDuff.Mode.SRC_ATOP) + // fallback option + if (loadList.isEmpty()) { + Log.w(LOG_TAG, "using fallback option to load packages") + val i = Intent(Intent.ACTION_MAIN, null) + i.addCategory(Intent.CATEGORY_LAUNCHER) + val allApps = packageManager.queryIntentActivities(i, 0) + for (ri in allApps) { + val app = AppInfo(ri.activityInfo.packageName, null, INVALID_USER) + val detailedAppInfo = DetailedAppInfo( + app, + ri.loadLabel(packageManager), + ri.activityInfo.loadIcon(packageManager), + false + ) + loadList.add(detailedAppInfo) + } } - // not setting it in any other case (yet), unable to find a good solution -} + loadList.sortBy { it.getCustomLabel(context) } -fun setSwitchColor(sw: Switch, trackColor: Int) { - if (Build.VERSION.SDK_INT >= 29) { - sw.trackDrawable.colorFilter = BlendModeColorFilter(trackColor, BlendMode.MULTIPLY) - } - else { - sw.trackDrawable.colorFilter = PorterDuffColorFilter(trackColor, PorterDuff.Mode.SRC_ATOP) + 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 } -// 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 +// 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() } + +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/Gesture.kt b/app/src/main/java/de/jrpie/android/launcher/Gesture.kt deleted file mode 100644 index 8d70340..0000000 --- a/app/src/main/java/de/jrpie/android/launcher/Gesture.kt +++ /dev/null @@ -1,144 +0,0 @@ -package de.jrpie.android.launcher - -import android.app.Activity -import android.content.Context - -/** - * @param id internal id to serialize the action. Used as a key in shared preferences. - * @param defaultsResource res id of array of default actions for the gesture. - * @param labelResource res id of the name of the gesture. - * @param animationIn res id of transition animation (in) when using the gesture to launch an app. - * @param animationOut res id of transition animation (out) when using the gesture to launch an app. - */ -enum class Gesture (val id: String, private val labelResource: Int, - private val defaultsResource: Int, - private val animationIn: Int = android.R.anim.fade_in, - private val animationOut: Int = android.R.anim.fade_out){ - VOLUME_UP("action_volumeUpApp", R.string.settings_gesture_vol_up, R.array.default_volume_up, 0,0), - VOLUME_DOWN("action_volumeDownApp", R.string.settings_gesture_vol_down, R.array.default_volume_down,0,0), - TIME("action_timeApp", R.string.settings_gesture_time, R.array.default_time), - DATE("action_dateApp", R.string.settings_gesture_date, R.array.default_date), - LONG_CLICK("action_longClickApp", R.string.settings_gesture_long_click, R.array.default_long_click, 0,0), - DOUBLE_CLICK("action_doubleClickApp", R.string.settings_gesture_double_click, R.array.default_double_click,0,0), - SWIPE_UP("action_upApp", R.string.settings_gesture_up, R.array.default_up, R.anim.bottom_up), - SWIPE_UP_LEFT_EDGE("action_up_leftApp", R.string.settings_gesture_up_left_edge, R.array.default_up_left, R.anim.bottom_up), - SWIPE_UP_RIGHT_EDGE("action_up_rightApp", R.string.settings_gesture_up_right_edge, R.array.default_up_right, R.anim.bottom_up), - SWIPE_UP_DOUBLE( "action_doubleUpApp", R.string.settings_gesture_double_up, R.array.default_double_up, R.anim.bottom_up), - SWIPE_DOWN("action_downApp", R.string.settings_gesture_down, R.array.default_down, R.anim.top_down), - SWIPE_DOWN_LEFT_EDGE("action_down_leftApp", R.string.settings_gesture_down_left_edge, R.array.default_down_left, R.anim.top_down), - SWIPE_DOWN_RIGHT_EDGE("action_down_rightApp", R.string.settings_gesture_down_right_edge, R.array.default_down_right, R.anim.top_down), - SWIPE_DOWN_DOUBLE("action_doubleDownApp", R.string.settings_gesture_double_down, R.array.default_double_down, R.anim.top_down), - SWIPE_LEFT("action_leftApp", R.string.settings_gesture_left, R.array.default_left, R.anim.right_left), - SWIPE_LEFT_TOP_EDGE("action_left_topApp", R.string.settings_gesture_left_top_edge, R.array.default_left_top, R.anim.right_left), - SWIPE_LEFT_BOTTOM_EDGE("action_left_bottomApp", R.string.settings_gesture_left_bottom_edge, R.array.default_left_bottom, R.anim.right_left), - SWIPE_LEFT_DOUBLE("action_doubleLeftApp", R.string.settings_gesture_double_left, R.array.default_double_left, R.anim.right_left), - SWIPE_RIGHT("action_rightApp", R.string.settings_gesture_right, R.array.default_right, R.anim.left_right), - SWIPE_RIGHT_TOP_EDGE("action_right_topApp", R.string.settings_gesture_right_top_edge, R.array.default_right_top, R.anim.left_right), - SWIPE_RIGHT_BOTTOM_EDGE("action_right_bottomApp", R.string.settings_gesture_right_bottom_edge, R.array.default_right_bottom, R.anim.left_right), - SWIPE_RIGHT_DOUBLE("action_doubleRightApp", R.string.settings_gesture_double_right, R.array.default_double_right, R.anim.left_right); - - enum class Edge{ - TOP, BOTTOM, LEFT, RIGHT - } - - fun getApp(context: Context): String { - return getPreferences(context).getString(this.id, "")!! - } - - fun removeApp(context: Context) { - getPreferences(context).edit() - .putString(this.id, "") // clear it - .apply() - } - - fun setApp(context: Context, app: String) { - getPreferences(context).edit() - .putString(this.id, app) - .apply() - } - - fun getLabel(context: Context): String { - return context.resources.getString(this.labelResource) - } - - fun pickDefaultApp(context: Context) : String { - return context.resources - .getStringArray(this.defaultsResource) - .firstOrNull { isInstalled(it, context) } - ?: "" - } - - fun getDoubleVariant(): Gesture { - return when(this) { - SWIPE_UP -> SWIPE_UP_DOUBLE - SWIPE_DOWN -> SWIPE_DOWN_DOUBLE - SWIPE_LEFT -> SWIPE_LEFT_DOUBLE - SWIPE_RIGHT -> SWIPE_RIGHT_DOUBLE - else -> this - } - } - - fun getEdgeVariant(edge: Edge): Gesture { - return when(edge) { - Edge.TOP -> - when(this) { - SWIPE_LEFT -> SWIPE_LEFT_TOP_EDGE - SWIPE_RIGHT -> SWIPE_RIGHT_TOP_EDGE - else -> this - } - Edge.BOTTOM -> - when(this) { - SWIPE_LEFT -> SWIPE_LEFT_BOTTOM_EDGE - SWIPE_RIGHT -> SWIPE_RIGHT_BOTTOM_EDGE - else -> this - } - Edge.LEFT -> - when(this) { - SWIPE_UP -> SWIPE_UP_LEFT_EDGE - SWIPE_DOWN -> SWIPE_DOWN_LEFT_EDGE - else -> this - } - Edge.RIGHT -> - when(this) { - SWIPE_UP -> SWIPE_UP_RIGHT_EDGE - SWIPE_DOWN -> SWIPE_DOWN_RIGHT_EDGE - else -> this - } - } - } - - fun isDoubleVariant(): Boolean { - return when(this){ - SWIPE_UP_DOUBLE, - SWIPE_DOWN_DOUBLE, - SWIPE_LEFT_DOUBLE, - SWIPE_RIGHT_DOUBLE -> true - else -> false - } - } - - fun isEdgeVariant(): Boolean { - return when(this){ - SWIPE_UP_RIGHT_EDGE, - SWIPE_UP_LEFT_EDGE, - SWIPE_DOWN_LEFT_EDGE, - SWIPE_DOWN_RIGHT_EDGE, - SWIPE_LEFT_TOP_EDGE, - SWIPE_LEFT_BOTTOM_EDGE, - SWIPE_RIGHT_TOP_EDGE, - SWIPE_RIGHT_BOTTOM_EDGE -> true - else -> false - } - } - - operator fun invoke(activity: Activity) { - launch(this.getApp(activity), activity, this.animationIn, this.animationOut) - } - - companion object { - fun byId(id: String): Gesture? { - return Gesture.values().firstOrNull {it.id == id } - } - } - -} diff --git a/app/src/main/java/de/jrpie/android/launcher/HomeActivity.kt b/app/src/main/java/de/jrpie/android/launcher/HomeActivity.kt deleted file mode 100644 index 3e7dd00..0000000 --- a/app/src/main/java/de/jrpie/android/launcher/HomeActivity.kt +++ /dev/null @@ -1,249 +0,0 @@ -package de.jrpie.android.launcher - -import android.content.Intent -import android.os.AsyncTask -import android.os.Bundle -import android.view.GestureDetector -import android.view.KeyEvent -import android.view.MotionEvent -import androidx.appcompat.app.AppCompatActivity -import androidx.core.view.GestureDetectorCompat -import de.jrpie.android.launcher.BuildConfig.VERSION_NAME -import de.jrpie.android.launcher.list.other.LauncherAction -import de.jrpie.android.launcher.tutorial.TutorialActivity -import kotlinx.android.synthetic.main.home.* -import java.text.SimpleDateFormat -import java.util.* -import kotlin.concurrent.fixedRateTimer -import kotlin.math.abs -import kotlin.math.max -import kotlin.math.min - -/** - * [HomeActivity] is the actual application Launcher, - * what makes this application special / unique. - * - * In this activity we display the date and time, - * and we listen for actions like tapping, swiping or button presses. - * - * As it also is the first thing that is started when someone opens Launcher, - * it also contains some logic related to the overall application: - * - Setting global variables (preferences etc.) - * - Opening the [TutorialActivity] on new installations - */ -class HomeActivity: UIObject, AppCompatActivity(), - GestureDetector.OnGestureListener, GestureDetector.OnDoubleTapListener { - - private var bufferedPointerCount = 1 // how many fingers on screen - private var pointerBufferTimer = Timer() - - private lateinit var mDetector: GestureDetectorCompat - - // timers - private var clockTimer = Timer() - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - - val preferences = getPreferences(this) - windowManager.defaultDisplay.getMetrics(displayMetrics) - - loadSettings(this) - - // First time opening the app: show Tutorial, else: check versions - if (!preferences.getBoolean(PREF_STARTED, false)) - startActivity(Intent(this, TutorialActivity::class.java)) - else when (preferences.getString(PREF_VERSION, "")) { - // Check versions, make sure transitions between versions go well - - VERSION_NAME -> { /* the version installed and used previously are the same */ } - "" -> { /* The version used before was pre- v1.3.0, - as version tracking started then */ - - /* - * before, the dominant and vibrant color of the `finn` and `dark` theme - * were not stored anywhere. Now they have to be stored: - * -> we just reset them using newly implemented functions - */ - when (getSavedTheme(this)) { - "finn" -> resetToDefaultTheme(this) - "dark" -> resetToDarkTheme(this) - } - - preferences.edit() - .putString(PREF_VERSION, VERSION_NAME) // save new version - .apply() - - // show the new tutorial - startActivity(Intent(this, TutorialActivity::class.java)) - } - } - - // Preload apps to speed up the Apps Recycler - AsyncTask.execute { loadApps(packageManager) } - - // Initialise layout - setContentView(R.layout.home) - } - - override fun onStart(){ - super.onStart() - - mDetector = GestureDetectorCompat(this, this) - mDetector.setOnDoubleTapListener(this) - - // for if the settings changed - loadSettings(this) - super.onStart() - } - - override fun onResume() { - super.onResume() - - // Applying the date / time format (changeable in settings) - val dFormat = getPreferences(this).getInt(PREF_DATE_FORMAT, 0) - val upperFMT = resources.getStringArray(R.array.settings_launcher_time_formats_upper) - val lowerFMT = resources.getStringArray(R.array.settings_launcher_time_formats_lower) - - val dateFormat = SimpleDateFormat(upperFMT[dFormat], Locale.getDefault()) - val timeFormat = SimpleDateFormat(lowerFMT[dFormat], Locale.getDefault()) - - clockTimer = fixedRateTimer("clockTimer", true, 0L, 100) { - this@HomeActivity.runOnUiThread { - val t = timeFormat.format(Date()) - if (home_lower_view.text != t) - home_lower_view.text = t - - val d = dateFormat.format(Date()) - if (home_upper_view.text != d) - home_upper_view.text = d - } - } - } - - override fun onPause() { - super.onPause() - clockTimer.cancel() - } - - override fun onKeyDown(keyCode: Int, event: KeyEvent?): Boolean { - when (keyCode) { - KeyEvent.KEYCODE_BACK -> LauncherAction.CHOOSE.launch(this) - KeyEvent.KEYCODE_VOLUME_UP -> Gesture.VOLUME_UP(this) - KeyEvent.KEYCODE_VOLUME_DOWN -> Gesture.VOLUME_DOWN(this) - } - return true - } - - override fun onFling(e1: MotionEvent?, e2: MotionEvent, dX: Float, dY: Float): Boolean { - - if (e1 == null) return false; - - val width = displayMetrics.widthPixels - val height = displayMetrics.heightPixels - - val diffX = e1.x - e2.x - val diffY = e1.y - e2.y - - val preferences = getPreferences(this) - - val doubleActions = preferences.getBoolean(PREF_DOUBLE_ACTIONS_ENABLED, false) - val edgeActions = preferences.getBoolean(PREF_EDGE_ACTIONS_ENABLED, false) - val edgeStrictness = 0.15 - // how distinguished the swipe has to be to launch something - // strictness = opposite of sensitivity. TODO - May have to be adjusted - val strictness = (4 / bufferedPointerCount) * ((100 - preferences.getInt(PREF_SLIDE_SENSITIVITY, 50)) / 50) - - var gesture = if(abs(diffX) > abs(diffY)) { // horizontal swipe - if (diffX > width / 4 && abs(diffX) > strictness * abs(diffY)) - Gesture.SWIPE_LEFT - else if (diffX < -width / 4 && abs(diffX) > strictness * abs(diffY)) - Gesture.SWIPE_RIGHT - else null - } else { // vertical swipe - // Only open if the swipe was not from the phones top edge - if (diffY < -height / 8 && abs(diffY) > strictness * abs(diffX) && e1.y > 100) - Gesture.SWIPE_DOWN - else if (diffY > height / 8 && abs(diffY) > strictness * abs(diffX)) - Gesture.SWIPE_UP - else null - } - - if (doubleActions && bufferedPointerCount > 1) { - gesture = gesture?.let(Gesture::getDoubleVariant) - } - - if (edgeActions) { - if(max(e1.x, e2.x) < edgeStrictness * width){ - gesture = gesture?.let{it.getEdgeVariant(Gesture.Edge.LEFT)}; - } else if (min(e1.x, e2.x) > (1-edgeStrictness) * width){ - gesture = gesture?.let{it.getEdgeVariant(Gesture.Edge.RIGHT)}; - } - - if(max(e1.y, e2.y) < edgeStrictness * height){ - gesture = gesture?.let{it.getEdgeVariant(Gesture.Edge.TOP)}; - } else if (min(e1.y, e2.y) > (1-edgeStrictness) * height){ - gesture = gesture?.let{it.getEdgeVariant(Gesture.Edge.BOTTOM)}; - } - } - gesture?.invoke(this) - - return true - } - - override fun onLongPress(event: MotionEvent) { - Gesture.LONG_CLICK(this) - } - - override fun onDoubleTap(event: MotionEvent): Boolean { - Gesture.DOUBLE_CLICK(this) - return false - } - - // Tooltip - override fun onSingleTapConfirmed(event: MotionEvent): Boolean { - - return false - } - - override fun onTouchEvent(event: MotionEvent): Boolean { - - // Buffer / Debounce the pointer count - if (event.pointerCount > bufferedPointerCount) { - bufferedPointerCount = event.pointerCount - pointerBufferTimer = fixedRateTimer("pointerBufferTimer", true, 300, 1000) { - bufferedPointerCount = 1 - this.cancel() // a non-recurring timer - } - } - - return if (mDetector.onTouchEvent(event)) { false } else { super.onTouchEvent(event) } - } - - override fun setOnClicks() { - - val preferences = getPreferences(this) - home_upper_view.setOnClickListener { - when (preferences.getInt(PREF_DATE_FORMAT, 0)) { - 0 -> Gesture.DATE(this) - else -> Gesture.TIME(this) - } - } - - home_lower_view.setOnClickListener { - when (preferences.getInt(PREF_DATE_FORMAT, 0)) { - 0 -> Gesture.TIME(this) - else -> Gesture.DATE(this) - } - } - } - - /* TODO: Remove those. For now they are necessary - * because this inherits from GestureDetector.OnGestureListener */ - override fun onDoubleTapEvent(event: MotionEvent): Boolean { return false } - override fun onDown(event: MotionEvent): Boolean { return false } - override fun onScroll(e1: MotionEvent?, e2: MotionEvent, dX: Float, dY: Float): Boolean { return false } - override fun onShowPress(event: MotionEvent) {} - override fun onSingleTapUp(event: MotionEvent): Boolean { return false } - -} diff --git a/app/src/main/java/de/jrpie/android/launcher/UIObject.kt b/app/src/main/java/de/jrpie/android/launcher/UIObject.kt deleted file mode 100644 index 894f948..0000000 --- a/app/src/main/java/de/jrpie/android/launcher/UIObject.kt +++ /dev/null @@ -1,22 +0,0 @@ -package de.jrpie.android.launcher - -import android.app.Activity - -/** - * 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. - */ -interface UIObject { - fun onStart() { - if (this is Activity) setWindowFlags(window) - - applyTheme() - setOnClicks() - adjustLayout() - } - - // Don't use actual themes, rather create them on the fly for faster theme-switching - fun applyTheme() { } - fun setOnClicks() { } - fun adjustLayout() { } -} \ 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 new file mode 100644 index 0000000..9a2dc62 --- /dev/null +++ b/app/src/main/java/de/jrpie/android/launcher/actions/Action.kt @@ -0,0 +1,100 @@ +package de.jrpie.android.launcher.actions + +import android.app.Activity +import android.content.Context +import android.content.SharedPreferences.Editor +import android.graphics.Rect +import android.graphics.drawable.Drawable +import android.widget.Toast +import de.jrpie.android.launcher.R +import de.jrpie.android.launcher.preferences.LauncherPreferences +import kotlinx.serialization.Serializable +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import androidx.core.content.edit + + +@Serializable +sealed interface Action { + fun invoke(context: Context, rect: Rect? = null): Boolean + fun label(context: Context): String + fun getIcon(context: Context): Drawable? + fun isAvailable(context: Context): Boolean + + // 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 { + + fun forGesture(gesture: Gesture): Action? { + val id = gesture.id + + val preferences = LauncherPreferences.getSharedPreferences() + val json = preferences.getString(id, "null")!! + return Json.decodeFromString(json) + } + + fun resetToDefaultActions(context: Context) { + 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) + } + } + } + } + + fun setActionForGesture(gesture: Gesture, action: Action?) { + if (action == null) { + clearActionForGesture(gesture) + return + } + LauncherPreferences.getSharedPreferences().edit { + action.bindToGesture(this, gesture.id) + } + } + + fun clearActionForGesture(gesture: Gesture) { + LauncherPreferences.getSharedPreferences().edit { + remove(gesture.id) + } + } + + fun launch( + action: Action?, + context: Context, + animationIn: Int = android.R.anim.fade_in, + animationOut: Int = android.R.anim.fade_out + ) { + 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 { + Toast.makeText( + context, + context.getString(R.string.toast_cant_open_message), + Toast.LENGTH_SHORT + ).show() + } + } + } +} \ 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 new file mode 100644 index 0000000..1446b13 --- /dev/null +++ b/app/src/main/java/de/jrpie/android/launcher/actions/AppAction.kt @@ -0,0 +1,81 @@ +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.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.AbstractAppInfo.Companion.INVALID_USER +import de.jrpie.android.launcher.apps.DetailedAppInfo +import de.jrpie.android.launcher.ui.list.apps.openSettings +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +@SerialName("action:app") +class AppAction(val app: AppInfo) : Action { + + override fun invoke(context: Context, rect: Rect?): Boolean { + val packageName = app.packageName + if (app.user != INVALID_USER) { + val launcherApps = + context.getSystemService(Service.LAUNCHER_APPS_SERVICE) as LauncherApps + app.getLauncherActivityInfo(context)?.let { app -> + Log.i("Launcher", "Starting ${this.app}") + launcherApps.startMainActivity(app.componentName, app.user, rect, null) + return true + } + } + + 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( + context, + R.style.AlertDialogCustom + ) + .setTitle(context.getString(R.string.alert_cant_open_title)) + .setMessage(context.getString(R.string.alert_cant_open_message)) + .setPositiveButton(android.R.string.ok) { _, _ -> + app.openSettings(context) + } + .setNegativeButton(android.R.string.cancel, null) + .setIcon(android.R.drawable.ic_dialog_info) + .show() + return true + } + return false + } + + override fun label(context: Context): String { + return DetailedAppInfo.fromAppInfo(app, context)?.getCustomLabel(context).toString() + } + + override fun getIcon(context: Context): Drawable? { + return DetailedAppInfo.fromAppInfo(app, context)?.getIcon(context) + } + + override fun isAvailable(context: Context): Boolean { + // check if app is installed + return DetailedAppInfo.fromAppInfo(app, 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/Gesture.kt b/app/src/main/java/de/jrpie/android/launcher/actions/Gesture.kt new file mode 100644 index 0000000..a2434e1 --- /dev/null +++ b/app/src/main/java/de/jrpie/android/launcher/actions/Gesture.kt @@ -0,0 +1,369 @@ +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 + +/** + * @param id internal id to serialize the action. Used as a key in shared preferences. + * @param defaultsResource res id of array of default actions for the gesture. + * @param labelResource res id of the name of the gesture. + * @param animationIn res id of transition animation (in) when using the gesture to launch an app. + * @param animationOut res id of transition animation (out) when using the gesture to launch an app. + */ +enum class Gesture( + val id: String, + private val labelResource: Int, + private val descriptionResource: Int, + internal val defaultsResource: Int, + private val animationIn: Int = android.R.anim.fade_in, + private val animationOut: Int = android.R.anim.fade_out +) { + VOLUME_UP( + "action.volume_up", + R.string.settings_gesture_vol_up, + R.string.settings_gesture_description_vol_up, + R.array.default_volume_up, + 0, + 0 + ), + VOLUME_DOWN( + "action.volume_down", + R.string.settings_gesture_vol_down, + R.string.settings_gesture_description_vol_down, + R.array.default_volume_down, 0, 0 + ), + TIME( + "action.time", + R.string.settings_gesture_time, + R.string.settings_gesture_description_time, + R.array.default_time + ), + DATE( + "action.date", + R.string.settings_gesture_date, + R.string.settings_gesture_description_date, + R.array.default_date + ), + LONG_CLICK( + "action.long_click", + R.string.settings_gesture_long_click, + R.string.settings_gesture_description_long_click, + R.array.default_long_click, 0, 0 + ), + DOUBLE_CLICK( + "action.double_click", + R.string.settings_gesture_double_click, + R.string.settings_gesture_description_double_click, + R.array.default_double_click, 0, 0 + ), + SWIPE_UP( + "action.up", + R.string.settings_gesture_up, + R.string.settings_gesture_description_up, + R.array.default_up, + R.anim.bottom_up + ), + SWIPE_UP_LEFT_EDGE( + "action.up_left", + R.string.settings_gesture_up_left_edge, + R.string.settings_gesture_description_up_left_edge, + R.array.default_up_left, + R.anim.bottom_up + ), + SWIPE_UP_RIGHT_EDGE( + "action.up_right", + R.string.settings_gesture_up_right_edge, + R.string.settings_gesture_description_up_right_edge, + 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, + R.string.settings_gesture_description_double_up, + R.array.default_double_up, + R.anim.bottom_up + ), + SWIPE_DOWN( + "action.down", + R.string.settings_gesture_down, + R.string.settings_gesture_description_down, + R.array.default_down, + R.anim.top_down + ), + SWIPE_DOWN_LEFT_EDGE( + "action.down_left", + R.string.settings_gesture_down_left_edge, + R.string.settings_gesture_description_down_left_edge, + R.array.default_down_left, + R.anim.top_down + ), + SWIPE_DOWN_RIGHT_EDGE( + "action.down_right", + R.string.settings_gesture_down_right_edge, + R.string.settings_gesture_description_down_right_edge, + 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, + R.string.settings_gesture_description_double_down, + R.array.default_double_down, + R.anim.top_down + ), + SWIPE_LEFT( + "action.left", + R.string.settings_gesture_left, + R.string.settings_gesture_description_left, + R.array.default_messengers, + R.anim.right_left + ), + SWIPE_LEFT_TOP_EDGE( + "action.left_top", + R.string.settings_gesture_left_top_edge, + R.string.settings_gesture_description_left_top_edge, + R.array.default_messengers, + R.anim.right_left + ), + SWIPE_LEFT_BOTTOM_EDGE( + "action.left_bottom", + R.string.settings_gesture_left_bottom_edge, + R.string.settings_gesture_description_left_bottom_edge, + 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, + R.string.settings_gesture_description_double_left, + R.array.default_messengers, + R.anim.right_left + ), + SWIPE_RIGHT( + "action.right", + R.string.settings_gesture_right, + R.string.settings_gesture_description_right, + R.array.default_right, + R.anim.left_right + ), + SWIPE_RIGHT_TOP_EDGE( + "action.right_top", + R.string.settings_gesture_right_top_edge, + R.string.settings_gesture_description_right_top_edge, + R.array.default_right_top, + R.anim.left_right + ), + SWIPE_RIGHT_BOTTOM_EDGE( + "action.right_bottom", + R.string.settings_gesture_right_bottom_edge, + R.string.settings_gesture_description_right_bottom_edge, + 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 { + TOP, BOTTOM, LEFT, RIGHT + } + + fun getLabel(context: Context): String { + return context.resources.getString(this.labelResource) + } + + fun getDescription(context: Context): String { + return context.resources.getString(this.descriptionResource) + } + + fun getDoubleVariant(): Gesture { + return when (this) { + SWIPE_UP -> SWIPE_UP_DOUBLE + SWIPE_DOWN -> SWIPE_DOWN_DOUBLE + SWIPE_LEFT -> SWIPE_LEFT_DOUBLE + SWIPE_RIGHT -> SWIPE_RIGHT_DOUBLE + else -> this + } + } + + fun getEdgeVariant(edge: Edge): Gesture { + return when (edge) { + Edge.TOP -> + when (this) { + SWIPE_LEFT -> SWIPE_LEFT_TOP_EDGE + SWIPE_RIGHT -> SWIPE_RIGHT_TOP_EDGE + else -> this + } + + Edge.BOTTOM -> + when (this) { + SWIPE_LEFT -> SWIPE_LEFT_BOTTOM_EDGE + SWIPE_RIGHT -> SWIPE_RIGHT_BOTTOM_EDGE + else -> this + } + + Edge.LEFT -> + when (this) { + SWIPE_UP -> SWIPE_UP_LEFT_EDGE + SWIPE_DOWN -> SWIPE_DOWN_LEFT_EDGE + else -> this + } + + Edge.RIGHT -> + when (this) { + SWIPE_UP -> SWIPE_UP_RIGHT_EDGE + SWIPE_DOWN -> SWIPE_DOWN_RIGHT_EDGE + else -> this + } + } + } + + 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, + SWIPE_DOWN_DOUBLE, + SWIPE_LEFT_DOUBLE, + SWIPE_RIGHT_DOUBLE -> true + + else -> false + } + } + + fun isEdgeVariant(): Boolean { + return when (this) { + SWIPE_UP_RIGHT_EDGE, + SWIPE_UP_LEFT_EDGE, + SWIPE_DOWN_LEFT_EDGE, + SWIPE_DOWN_RIGHT_EDGE, + SWIPE_LEFT_TOP_EDGE, + SWIPE_LEFT_BOTTOM_EDGE, + SWIPE_RIGHT_TOP_EDGE, + SWIPE_RIGHT_BOTTOM_EDGE -> true + + else -> false + } + } + + fun isEnabled(): Boolean { + if (isEdgeVariant()) { + return LauncherPreferences.enabled_gestures().edgeSwipe() + } + if (isDoubleVariant()) { + return LauncherPreferences.enabled_gestures().doubleSwipe() + } + return true + } + + 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.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 new file mode 100644 index 0000000..6ba467e --- /dev/null +++ b/app/src/main/java/de/jrpie/android/launcher/actions/LauncherAction.kt @@ -0,0 +1,341 @@ +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.media.AudioManager +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.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, + private val canReachSettings: Boolean = false, + val available: (Context) -> Boolean = { true }, +) : Action { + SETTINGS( + "settings", + R.string.list_other_settings, + R.drawable.baseline_settings_24, + ::openSettings, + true + ), + CHOOSE( + "choose", + R.string.list_other_list, + R.drawable.baseline_menu_24, + ::openAppsList, + true + ), + CHOOSE_FROM_FAVORITES( + "choose_from_favorites", + R.string.list_other_list_favorites, + R.drawable.baseline_favorite_24, + { 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( + "volume_up", + R.string.list_other_volume_up, + R.drawable.baseline_volume_up_24, + { 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, 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( + "next_track", + R.string.list_other_track_next, + R.drawable.baseline_skip_next_24, + { 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) } + ), + EXPAND_NOTIFICATIONS_PANEL( + "expand_notifications_panel", + R.string.list_other_expand_notifications_panel, + R.drawable.baseline_notifications_24, + ::expandNotificationsPanel + ), + EXPAND_SETTINGS_PANEL( + "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( + "lock_screen", + R.string.list_other_lock_screen, + R.drawable.baseline_lock_24, + { c -> LauncherPreferences.actions().lockMethod().lockOrEnable(c) } + ), + TORCH( + "toggle_torch", + R.string.list_other_torch, + R.drawable.baseline_flashlight_on_24, + ::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, {}); + + override fun invoke(context: Context, rect: Rect?): Boolean { + launch(context) + return true + } + + override fun label(context: Context): String { + return context.getString(label) + } + + override fun getIcon(context: Context): Drawable? { + return AppCompatResources.getDrawable(context, icon) + } + + override fun isAvailable(context: Context): Boolean { + return this.available(context) + } + + override fun canReachSettings(): Boolean { + return this.canReachSettings + } + + companion object { + fun byId(id: String): LauncherAction? { + return entries.singleOrNull { it.id == id } + } + } +} + + +/* Media player actions */ +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, key, 0) + mAudioManager.dispatchMediaKeyEvent(downEvent) + val upEvent = KeyEvent(eventTime, eventTime, KeyEvent.ACTION_UP, key, 0) + mAudioManager.dispatchMediaKeyEvent(upEvent) + +} + +private fun audioVolumeAdjust(context: Context, direction: Int) { + val audioManager = + context.getSystemService(Context.AUDIO_SERVICE) as AudioManager + + audioManager.adjustStreamVolume( + AudioManager.STREAM_MUSIC, + direction, + AudioManager.FLAG_SHOW_UI + ) +} + +/* End media player actions */ + +private fun toggleTorch(context: Context) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) { + Toast.makeText( + context, + context.getString(R.string.alert_requires_android_m), + Toast.LENGTH_LONG + ).show() + return + } + + (context.applicationContext as Application).torchManager?.toggleTorch(context) +} + +private fun expandNotificationsPanel(context: Context) { + /* https://stackoverflow.com/a/15582509 */ + try { + @Suppress("SpellCheckingInspection") + val statusBarService: Any? = context.getSystemService("statusbar") + val statusBarManager = Class.forName("android.app.StatusBarManager") + val showStatusBar = statusBarManager.getMethod("expandNotificationsPanel") + showStatusBar.invoke(statusBarService) + } catch (e: Exception) { + Toast.makeText( + context, + context.getString(R.string.alert_cant_expand_status_bar_panel), + Toast.LENGTH_LONG + ).show() + } +} + + +private fun expandSettingsPanel(context: Context) { + /* https://stackoverflow.com/a/31898506 */ + try { + @Suppress("SpellCheckingInspection") + val statusBarService: Any? = context.getSystemService("statusbar") + val statusBarManager = Class.forName("android.app.StatusBarManager") + val showStatusBar = statusBarManager.getMethod("expandSettingsPanel") + showStatusBar.invoke(statusBarService) + } catch (e: Exception) { + Toast.makeText( + context, + context.getString(R.string.alert_cant_expand_status_bar_panel), + Toast.LENGTH_LONG + ).show() + } +} + +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, + private: Boolean = false +) { + val intent = Intent(context, ListActivity::class.java) + intent.putExtra("intention", ListActivity.ListActivityIntention.VIEW.toString()) + intent.putExtra( + "favoritesVisibility", + if (favorite) { + AppFilter.Companion.AppSetVisibility.EXCLUSIVE + } else { + AppFilter.Companion.AppSetVisibility.VISIBLE + } + ) + intent.putExtra( + "hiddenVisibility", + if (hidden) { + AppFilter.Companion.AppSetVisibility.EXCLUSIVE + } else { + 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/TorchManager.kt b/app/src/main/java/de/jrpie/android/launcher/actions/TorchManager.kt new file mode 100644 index 0000000..7e694c6 --- /dev/null +++ b/app/src/main/java/de/jrpie/android/launcher/actions/TorchManager.kt @@ -0,0 +1,90 @@ +package de.jrpie.android.launcher.actions + +import android.content.Context +import android.hardware.camera2.CameraAccessException +import android.hardware.camera2.CameraCharacteristics +import android.hardware.camera2.CameraManager +import android.os.Build +import android.os.Build.VERSION_CODES +import android.os.Handler +import android.os.Looper +import android.widget.Toast +import androidx.annotation.RequiresApi +import de.jrpie.android.launcher.R + +@RequiresApi(VERSION_CODES.M) +class TorchManager(context: Context) { + + private val camera = getCameraId(context) + private var torchEnabled = false + + private val torchCallback = object : CameraManager.TorchCallback() { + override fun onTorchModeChanged(cameraId: String, enabled: Boolean) { + synchronized(this@TorchManager) { + if (cameraId == camera) { + torchEnabled = enabled + } + } + } + } + + init { + registerCallback(context) + } + + private fun getCameraId(context: Context): String? { + val cameraManager = + context.getSystemService(Context.CAMERA_SERVICE) as CameraManager + + return cameraManager.cameraIdList.firstOrNull { c -> + cameraManager + .getCameraCharacteristics(c) + .get(CameraCharacteristics.FLASH_INFO_AVAILABLE) ?: false + } + } + + private fun registerCallback(context: Context) { + val cameraManager = + context.getSystemService(Context.CAMERA_SERVICE) as CameraManager + + cameraManager.registerTorchCallback( + torchCallback, + Handler(Looper.getMainLooper()) + ) + } + + fun toggleTorch(context: Context) { + synchronized(this) { + val cameraManager = + context.getSystemService(Context.CAMERA_SERVICE) as CameraManager + + if (camera == null) { + Toast.makeText( + context, + context.getString(R.string.alert_no_torch_found), + Toast.LENGTH_LONG + ).show() + return + } + + try { + if (!torchEnabled && Build.VERSION.SDK_INT >= VERSION_CODES.TIRAMISU) { + cameraManager.turnOnTorchWithStrengthLevel( + camera, + cameraManager.getCameraCharacteristics(camera) + .get(CameraCharacteristics.FLASH_INFO_STRENGTH_MAXIMUM_LEVEL) ?: 1 + ) + } else { + cameraManager.setTorchMode(camera, !torchEnabled) + } + + } catch (e: CameraAccessException) { + Toast.makeText( + context, + context.getString(R.string.alert_torch_access_exception), + Toast.LENGTH_LONG + ).show() + } + } + } +} \ No newline at end of file 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 new file mode 100644 index 0000000..7cb32d9 --- /dev/null +++ b/app/src/main/java/de/jrpie/android/launcher/actions/lock/LauncherAccessibilityService.kt @@ -0,0 +1,151 @@ +package de.jrpie.android.launcher.actions.lock + +import android.accessibilityservice.AccessibilityService +import android.app.AlertDialog +import android.content.Context +import android.content.Intent +import android.os.Build +import android.provider.Settings +import android.util.Log +import android.view.accessibility.AccessibilityEvent +import android.widget.CheckBox +import android.widget.Toast +import de.jrpie.android.launcher.R + +class LauncherAccessibilityService : AccessibilityService() { + override fun onInterrupt() {} + + override fun onAccessibilityEvent(event: AccessibilityEvent?) { + // Intentionally left blank, we are not interested in any AccessibilityEvents. + // DO NOT ADD ANY CODE HERE! + } + + 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" + + private fun invoke(context: Context, action: String, failureMessageRes: Int) { + try { + context.startService( + Intent( + context, + LauncherAccessibilityService::class.java + ).apply { + this.action = action + }) + } catch (_: Exception) { + Toast.makeText( + context, + 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, + Settings.Secure.ENABLED_ACCESSIBILITY_SERVICES + ) ?: return false + + return enabledServices.split(":") + .contains("${context.packageName}/${LauncherAccessibilityService::class.java.name}") + .also { Log.d(TAG, "Accessibility Service enabled: $it") } + } + + fun showEnableDialog(context: Context) { + AlertDialog.Builder(context, R.style.AlertDialogDanger).apply { + setView(R.layout.dialog_consent_accessibility) + setTitle(R.string.dialog_consent_accessibility_title) + setPositiveButton(R.string.dialog_consent_accessibility_ok) { _, _ -> + invoke(context, ACTION_REQUEST_ENABLE, R.string.alert_enable_accessibility_failed) + } + setNegativeButton(R.string.dialog_cancel) { _, _ -> } + }.create().also { it.show() }.apply { + val buttonOk = getButton(AlertDialog.BUTTON_POSITIVE) + val checkboxes = listOf( + findViewById(R.id.dialog_consent_accessibility_checkbox_1), + findViewById(R.id.dialog_consent_accessibility_checkbox_2), + findViewById(R.id.dialog_consent_accessibility_checkbox_3), + findViewById(R.id.dialog_consent_accessibility_checkbox_4), + ) + val update = { + buttonOk.isEnabled = checkboxes.map { b -> b?.isChecked == true }.all { it } + } + update() + checkboxes.forEach { c -> + c?.setOnClickListener { _ -> update() } + } + } + } + } + + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + intent?.action?.let { action -> + if (!isEnabled(this)) { + Toast.makeText( + this, + getString(R.string.toast_accessibility_service_not_enabled), + Toast.LENGTH_LONG + ).show() + requestEnable() + return START_NOT_STICKY + } + + when (action) { + ACTION_REQUEST_ENABLE -> {} // do nothing + ACTION_LOCK_SCREEN -> handleLockScreen() + ACTION_RECENT_APPS -> performGlobalAction(GLOBAL_ACTION_RECENTS) + } + } + return super.onStartCommand(intent, flags, startId) + } + + private fun requestEnable() { + startActivity( + Intent(Settings.ACTION_ACCESSIBILITY_SETTINGS).addFlags( + Intent.FLAG_ACTIVITY_NEW_TASK + ) + ) + } + + private fun handleLockScreen() { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.P) { + Toast.makeText( + this, + getText(R.string.toast_lock_screen_not_supported), + Toast.LENGTH_SHORT + ).show() + return + } + + val success = performGlobalAction(GLOBAL_ACTION_LOCK_SCREEN) + if (!success) { + Toast.makeText( + this, + getText(R.string.alert_lock_screen_failed), + Toast.LENGTH_LONG + ).show() + requestEnable() + } + } +} \ No newline at end of file diff --git a/app/src/main/java/de/jrpie/android/launcher/actions/lock/LauncherDeviceAdmin.kt b/app/src/main/java/de/jrpie/android/launcher/actions/lock/LauncherDeviceAdmin.kt new file mode 100644 index 0000000..47c57d2 --- /dev/null +++ b/app/src/main/java/de/jrpie/android/launcher/actions/lock/LauncherDeviceAdmin.kt @@ -0,0 +1,56 @@ +package de.jrpie.android.launcher.actions.lock + +import android.app.admin.DeviceAdminReceiver +import android.app.admin.DevicePolicyManager +import android.content.ComponentName +import android.content.Context +import android.content.Intent +import android.widget.Toast +import de.jrpie.android.launcher.R + +class LauncherDeviceAdmin : DeviceAdminReceiver() { + companion object { + private fun getComponentName(context: Context): ComponentName { + return ComponentName(context, LauncherDeviceAdmin::class.java) + } + + private fun requestDeviceAdmin(context: Context) { + + val intent = Intent(DevicePolicyManager.ACTION_ADD_DEVICE_ADMIN).apply { + putExtra(DevicePolicyManager.EXTRA_DEVICE_ADMIN, getComponentName(context)) + putExtra( + DevicePolicyManager.EXTRA_ADD_EXPLANATION, + context.getString(R.string.device_admin_explanation) + ) + } + context.startActivity(intent) + } + + fun isDeviceAdmin(context: Context): Boolean { + val dpm = context.getSystemService(Context.DEVICE_POLICY_SERVICE) as DevicePolicyManager + return dpm.isAdminActive(getComponentName(context)) + } + + private fun assertDeviceAdmin(context: Context): Boolean { + val dpm = context.getSystemService(Context.DEVICE_POLICY_SERVICE) as DevicePolicyManager + if (!dpm.isAdminActive(getComponentName(context))) { + Toast.makeText( + context, + context.getString(R.string.toast_device_admin_not_enabled), + Toast.LENGTH_LONG + ).show() + requestDeviceAdmin(context) + return false + } + return true + } + + fun lockScreen(context: Context) { + + assertDeviceAdmin(context) || return + + val dpm = context.getSystemService(Context.DEVICE_POLICY_SERVICE) as DevicePolicyManager + dpm.lockNow() + } + } +} \ No newline at end of file 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 new file mode 100644 index 0000000..93b4cbf --- /dev/null +++ b/app/src/main/java/de/jrpie/android/launcher/actions/lock/LockMethod.kt @@ -0,0 +1,71 @@ +package de.jrpie.android.launcher.actions.lock + +import android.content.Context +import android.os.Build +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 + + +enum class LockMethod( + private val lock: (Context) -> Unit, + private val isEnabled: (Context) -> Boolean, + private val enable: (Context) -> Unit +) { + DEVICE_ADMIN( + LauncherDeviceAdmin::lockScreen, + LauncherDeviceAdmin::isDeviceAdmin, + LauncherDeviceAdmin::lockScreen + ), + ACCESSIBILITY_SERVICE( + LauncherAccessibilityService::lockScreen, + LauncherAccessibilityService::isEnabled, + LauncherAccessibilityService::showEnableDialog + ), + ; + + fun lockOrEnable(context: Context) { + if (!this.isEnabled(context)) { + chooseMethod(context) + return + } + this.lock(context) + } + + companion object { + fun chooseMethod(context: Context) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.P || + ! BuildConfig.USE_ACCESSIBILITY_SERVICE) { + // only device admin is available + setMethod(context, DEVICE_ADMIN) + return + } + 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