diff --git a/README.md b/README.md index b29b44d..016b362 100644 --- a/README.md +++ b/README.md @@ -14,12 +14,6 @@ µLauncher is an Android home screen that lets you launch apps using swipe gestures and button presses. It is *minimal, efficient and free of distraction*. -Your home screen only displays the date, time and a wallpaper. -Pressing back or swiping up (this can be configured) opens a list -of all installed apps, which can be searched efficiently. - -This is a fork of [finnmglas's app Launcher][original-repo]. - Get it on F-Droid Get it on Accrescent @@ -51,6 +45,45 @@ You can also [get it on Google Play](https://play.google.com/store/apps/details? height="400"> +µ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, + - draw < / > / V / Λ + - click on date / time, + - double click, + - long click, + - back button. + +To every gesture you can bind one of the following actions: + - launch an app, + - open a list of all / favorite / private apps, + - open µLauncher settings, + - toggle private space lock, + - lock the screen, + - toggle the torch, + - volume up / down, + - go to previous / next audio track. + + + +µLauncher is compatible with [work profile](https://www.android.com/enterprise/work-profile/), +so apps like [Shelter](https://gitea.angry.im/PeterCxy/Shelter) can be used. + +By default the font is set to [Hack][hack-font], but other fonts can be selected. + + + ## Contributing There are several ways to contribute to this app: @@ -63,34 +96,10 @@ There are several ways to contribute to this app: - Open a new pull request. -See [BUILD.md](BUILD.md) for instructions how to build this project. +See [build.md](docs/build.md) for instructions how to build this project. The [CI pipeline](https://github.com/jrpie/Launcher/actions) automatically creates debug builds. Note that those are not signed. -## Notable changes compared to [Finn's Launcher][original-repo]: - -* Edge gestures: There is a setting to allow distinguishing swiping at the edges of the screen from swiping in the center. -* Compatible with [work profile](https://www.android.com/enterprise/work-profile/), so apps like [Shelter](https://gitea.angry.im/PeterCxy/Shelter) can be used. -* The home button now works as expected. - -### Visual -* This app uses the system wallpaper instead of a custom solution. -* The font has been changed to [Hack][hack-font]. -* Font Awesome Icons were replaced by Material icons. -* The gear button on the home screen was removed. Instead pressing back opens the list of applications and the app settings are accessible from there. - - -### Search -* The search algorithm was modified to prefer matches at the beginning of the app name, i.e. when searching for `"te"`, `"termux"` is sorted before `"notes"`. -* The search bar was moved to the bottom of the screen. - -### Technical -* Small improvements to the gesture detection. -* Different apps set as default. -* Package name was changed to `de.jrpie.android.launcher` to avoid clashing with the original app. -* Dropped support for API < 21 (i.e. pre Lollypop) -* Some refactoring ---- --- [hack-font]: https://sourcefoundry.org/hack/ [original-repo]: https://github.com/finnmglas/Launcher diff --git a/app/src/main/java/de/jrpie/android/launcher/actions/Gesture.kt b/app/src/main/java/de/jrpie/android/launcher/actions/Gesture.kt index 34e053e..a4f25b4 100644 --- a/app/src/main/java/de/jrpie/android/launcher/actions/Gesture.kt +++ b/app/src/main/java/de/jrpie/android/launcher/actions/Gesture.kt @@ -1,6 +1,7 @@ package de.jrpie.android.launcher.actions import android.content.Context +import android.util.Log import de.jrpie.android.launcher.R import de.jrpie.android.launcher.preferences.LauncherPreferences @@ -169,6 +170,54 @@ enum class Gesture( 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, @@ -267,6 +316,7 @@ enum class Gesture( } operator fun invoke(context: Context) { + Log.i("Launcher", "Detected gesture: $this") val action = Action.forGesture(this) Action.launch(action, context, this.animationIn, this.animationOut) } diff --git a/app/src/main/java/de/jrpie/android/launcher/ui/HomeActivity.kt b/app/src/main/java/de/jrpie/android/launcher/ui/HomeActivity.kt index b41eff3..973e0ca 100644 --- a/app/src/main/java/de/jrpie/android/launcher/ui/HomeActivity.kt +++ b/app/src/main/java/de/jrpie/android/launcher/ui/HomeActivity.kt @@ -6,14 +6,11 @@ import android.content.res.Resources import android.os.Build import android.os.Bundle import android.util.DisplayMetrics -import android.view.GestureDetector import android.view.KeyEvent import android.view.MotionEvent import android.view.View -import android.view.ViewConfiguration import android.window.OnBackInvokedDispatcher import androidx.appcompat.app.AppCompatActivity -import androidx.core.view.GestureDetectorCompat import androidx.core.view.isVisible import de.jrpie.android.launcher.R import de.jrpie.android.launcher.actions.Action @@ -23,13 +20,6 @@ import de.jrpie.android.launcher.databinding.HomeBinding import de.jrpie.android.launcher.preferences.LauncherPreferences import de.jrpie.android.launcher.ui.tutorial.TutorialActivity import java.util.Locale -import java.util.Timer -import kotlin.concurrent.fixedRateTimer -import kotlin.math.abs -import kotlin.math.max -import kotlin.math.min -import kotlin.math.tan - /** * [HomeActivity] is the actual application Launcher, @@ -43,10 +33,10 @@ import kotlin.math.tan * - Setting global variables (preferences etc.) * - Opening the [TutorialActivity] on new installations */ -class HomeActivity : UIObject, AppCompatActivity(), - GestureDetector.OnGestureListener, GestureDetector.OnDoubleTapListener { +class HomeActivity : UIObject, AppCompatActivity() { private lateinit var binding: HomeBinding + private lateinit var touchGestureDetector: TouchGestureDetector private var sharedPreferencesListener = SharedPreferences.OnSharedPreferenceChangeListener { _, prefKey -> @@ -61,22 +51,29 @@ class HomeActivity : UIObject, AppCompatActivity(), } } - private var edgeWidth = 0.15f - - private var bufferedPointerCount = 1 // how many fingers on screen - private var pointerBufferTimer = Timer() - - private lateinit var mDetector: GestureDetectorCompat - - override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) super.onCreate() + + val displayMetrics = DisplayMetrics() + windowManager.defaultDisplay.getMetrics(displayMetrics) + + val width = displayMetrics.widthPixels + val height = displayMetrics.heightPixels + + touchGestureDetector = TouchGestureDetector( + this, + width, + height, + LauncherPreferences.enabled_gestures().edgeSwipeEdgeWidth() / 100f + ) + // Initialise layout binding = HomeBinding.inflate(layoutInflater) setContentView(binding.root) + // Handle back key / gesture on Android 13+, cf. onKeyDown() if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { onBackInvokedDispatcher.registerOnBackInvokedCallback( @@ -95,9 +92,6 @@ class HomeActivity : UIObject, AppCompatActivity(), override fun onStart() { super.onStart() - mDetector = GestureDetectorCompat(this, this) - mDetector.setOnDoubleTapListener(this) - super.onStart() LauncherPreferences.getSharedPreferences() @@ -172,7 +166,8 @@ class HomeActivity : UIObject, AppCompatActivity(), override fun onResume() { super.onResume() - edgeWidth = LauncherPreferences.enabled_gestures().edgeSwipeEdgeWidth() / 100f + touchGestureDetector.edgeWidth = + LauncherPreferences.enabled_gestures().edgeSwipeEdgeWidth() / 100f initClock() updateSettingsFallbackButtonVisibility() @@ -211,95 +206,8 @@ class HomeActivity : UIObject, AppCompatActivity(), return true } - override fun onFling(e1: MotionEvent?, e2: MotionEvent, dX: Float, dY: Float): Boolean { - - if (e1 == null) return false - - - val displayMetrics = DisplayMetrics() - windowManager.defaultDisplay.getMetrics(displayMetrics) - - val width = displayMetrics.widthPixels - val height = displayMetrics.heightPixels - - val diffX = e1.x - e2.x - val diffY = e1.y - e2.y - - val doubleActions = LauncherPreferences.enabled_gestures().doubleSwipe() - val edgeActions = LauncherPreferences.enabled_gestures().edgeSwipe() - - val threshold = ViewConfiguration.get(this).scaledTouchSlop - val angularThreshold = tan(Math.PI / 6) - - var gesture = if (angularThreshold * abs(diffX) > abs(diffY)) { // horizontal swipe - if (diffX > threshold) - Gesture.SWIPE_LEFT - else if (diffX < -threshold) - Gesture.SWIPE_RIGHT - else null - } else if (angularThreshold * abs(diffY) > abs(diffX)) { // vertical swipe - // Only open if the swipe was not from the phones top edge - // TODO: replace 100px by sensible dp value (e.g. twice the height of the status bar) - if (diffY < -threshold && e1.y > 100) - Gesture.SWIPE_DOWN - else if (diffY > threshold) - Gesture.SWIPE_UP - else null - } else null - - if (doubleActions && bufferedPointerCount > 1) { - gesture = gesture?.let(Gesture::getDoubleVariant) - } - - if (edgeActions) { - if (max(e1.x, e2.x) < edgeWidth * width) { - gesture = gesture?.getEdgeVariant(Gesture.Edge.LEFT) - } else if (min(e1.x, e2.x) > (1 - edgeWidth) * width) { - gesture = gesture?.getEdgeVariant(Gesture.Edge.RIGHT) - } - - if (max(e1.y, e2.y) < edgeWidth * height) { - gesture = gesture?.getEdgeVariant(Gesture.Edge.TOP) - } else if (min(e1.y, e2.y) > (1 - edgeWidth) * height) { - gesture = gesture?.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) - } + return touchGestureDetector.onTouchEvent(event) || super.onTouchEvent(event) } override fun setOnClicks() { @@ -329,16 +237,4 @@ class HomeActivity : UIObject, AppCompatActivity(), override fun isHomeScreen(): Boolean { return true } - - - /* 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/ui/TouchGestureDetector.kt b/app/src/main/java/de/jrpie/android/launcher/ui/TouchGestureDetector.kt new file mode 100644 index 0000000..df633af --- /dev/null +++ b/app/src/main/java/de/jrpie/android/launcher/ui/TouchGestureDetector.kt @@ -0,0 +1,241 @@ +package de.jrpie.android.launcher.ui + +import android.content.Context +import android.view.MotionEvent +import android.view.ViewConfiguration +import de.jrpie.android.launcher.actions.Gesture +import de.jrpie.android.launcher.preferences.LauncherPreferences +import kotlin.math.abs +import kotlin.math.max +import kotlin.math.min +import kotlin.math.tan + +class TouchGestureDetector( + private val context: Context, + val width: Int, + val height: Int, + var edgeWidth: Float +) { + private val ANGULAR_THRESHOLD = tan(Math.PI / 6) + private val TOUCH_SLOP: Int + private val TOUCH_SLOP_SQUARE: Int + private val DOUBLE_TAP_SLOP: Int + private val DOUBLE_TAP_SLOP_SQUARE: Int + private val LONG_PRESS_TIMEOUT: Int + private val TAP_TIMEOUT: Int + private val DOUBLE_TAP_TIMEOUT: Int + + private val MIN_TRIANGLE_HEIGHT = 250 + + + data class Vector(val x: Float, val y: Float) { + fun absSquared(): Float { + return this.x * this.x + this.y * this.y + } + fun plus(vector: Vector): Vector { + return Vector(this.x + vector.x, this.y + vector.y) + } + fun max(other: Vector): Vector { + return Vector(max(this.x, other.x), max(this.y, other.y)) + } + fun min(other: Vector): Vector { + return Vector(min(this.x, other.x), min(this.y, other.y)) + } + operator fun minus(vector: Vector): Vector { + return Vector(this.x - vector.x, this.y - vector.y) + } + } + + + class PointerPath( + val number: Int, + val start: Vector, + var last: Vector = start + ) { + var min = Vector(Float.POSITIVE_INFINITY, Float.POSITIVE_INFINITY) + var max = Vector(Float.NEGATIVE_INFINITY, Float.NEGATIVE_INFINITY) + fun sizeSquared(): Float { + return (max - min).absSquared() + } + fun getDirection(): Vector { + return last - start + } + fun update(vector: Vector) { + min = min.min(vector) + max = max.max(vector) + last = vector + } + } + private fun PointerPath.isTap(): Boolean { + return sizeSquared() < TOUCH_SLOP_SQUARE + } + + init { + val configuration = ViewConfiguration.get(context) + TOUCH_SLOP = configuration.scaledTouchSlop + TOUCH_SLOP_SQUARE = TOUCH_SLOP * TOUCH_SLOP + DOUBLE_TAP_SLOP = configuration.scaledDoubleTapSlop + DOUBLE_TAP_SLOP_SQUARE = DOUBLE_TAP_SLOP * DOUBLE_TAP_SLOP + + LONG_PRESS_TIMEOUT = ViewConfiguration.getLongPressTimeout() + TAP_TIMEOUT = ViewConfiguration.getTapTimeout() + DOUBLE_TAP_TIMEOUT = ViewConfiguration.getDoubleTapTimeout() + } + + private var paths = HashMap() + + private var lastTappedTime = 0L + private var lastTappedLocation: Vector? = null + + fun onTouchEvent(event: MotionEvent): Boolean { + val pointerIdToIndex = + (0.. abs(direction.y)) { // horizontal swipe + if (direction.x > TOUCH_SLOP) + Gesture.SWIPE_RIGHT + else if (direction.x < -TOUCH_SLOP) + Gesture.SWIPE_LEFT + else null + } else if (ANGULAR_THRESHOLD * abs(direction.y) > abs(direction.x)) { // vertical swipe + if (direction.y < -TOUCH_SLOP) + Gesture.SWIPE_UP + else if (direction.y > TOUCH_SLOP) + Gesture.SWIPE_DOWN + else null + } else null + } + + private fun classifyPaths(paths: Map, timeStart: Long, timeEnd: Long) { + val duration = timeEnd - timeStart + val pointerCount = paths.entries.size + if (paths.entries.isEmpty()) { + return + } + + val mainPointerPath = paths.entries.firstOrNull { it.value.number == 0 }?.value ?: return + + // Ignore swipes at the very top, since this interferes with the status bar. + // TODO: replace 100px by sensible dp value (e.g. twice the height of the status bar) + if (paths.entries.any { it.value.start.y < 100 }) { + return + } + + if (pointerCount == 1 && mainPointerPath.isTap()) { + // detect taps + + if (duration in 0..TAP_TIMEOUT) { + if (timeStart - lastTappedTime < DOUBLE_TAP_TIMEOUT && + lastTappedLocation?.let { + (mainPointerPath.last - it).absSquared() < DOUBLE_TAP_SLOP_SQUARE} == true + ) { + Gesture.DOUBLE_CLICK.invoke(context) + } else { + lastTappedTime = timeEnd + lastTappedLocation = mainPointerPath.last + } + } else if (duration > LONG_PRESS_TIMEOUT) { + // TODO: Don't wait until the finger is lifted. + // Instead set a timer to start long click as soon as LONG_PRESS_TIMEOUT is reached + Gesture.LONG_CLICK.invoke(context) + } + } else { + // detect swipes + + val doubleActions = LauncherPreferences.enabled_gestures().doubleSwipe() + val edgeActions = LauncherPreferences.enabled_gestures().edgeSwipe() + + var gesture = getGestureForDirection(mainPointerPath.getDirection()) + + if (doubleActions && pointerCount > 1) { + if (paths.entries.any { getGestureForDirection(it.value.getDirection()) != gesture }) { + // the directions of the pointers don't match + return + } + gesture = gesture?.let(Gesture::getDoubleVariant) + } + + // detect triangles + val startEndMin = mainPointerPath.start.min(mainPointerPath.last) + val startEndMax = mainPointerPath.start.max(mainPointerPath.last) + when (gesture) { + Gesture.SWIPE_DOWN -> { + if(startEndMax.x + MIN_TRIANGLE_HEIGHT < mainPointerPath.max.x) { + gesture = Gesture.SWIPE_LARGER + } else if (startEndMin.x - MIN_TRIANGLE_HEIGHT > mainPointerPath.min.x) { + gesture = Gesture.SWIPE_SMALLER + } + } + Gesture.SWIPE_UP -> { + if(startEndMax.x + MIN_TRIANGLE_HEIGHT < mainPointerPath.max.x) { + gesture = Gesture.SWIPE_LARGER_REVERSE + } else if (startEndMin.x - MIN_TRIANGLE_HEIGHT > mainPointerPath.min.x) { + gesture = Gesture.SWIPE_SMALLER_REVERSE + } + } + Gesture.SWIPE_RIGHT -> { + if(startEndMax.y + MIN_TRIANGLE_HEIGHT < mainPointerPath.max.y) { + gesture = Gesture.SWIPE_V + } else if (startEndMin.y - MIN_TRIANGLE_HEIGHT > mainPointerPath.min.y) { + gesture = Gesture.SWIPE_LAMBDA + } + } + Gesture.SWIPE_LEFT -> { + if(startEndMax.y + MIN_TRIANGLE_HEIGHT < mainPointerPath.max.y) { + gesture = Gesture.SWIPE_V_REVERSE + } else if (startEndMin.y - MIN_TRIANGLE_HEIGHT > mainPointerPath.min.y) { + gesture = Gesture.SWIPE_LAMBDA_REVERSE + } + } + else -> { } + } + + if (edgeActions) { + if (mainPointerPath.max.x < edgeWidth * width) { + gesture = gesture?.getEdgeVariant(Gesture.Edge.LEFT) + } else if (mainPointerPath.min.x > (1 - edgeWidth) * width) { + gesture = gesture?.getEdgeVariant(Gesture.Edge.RIGHT) + } + + if (mainPointerPath.max.y < edgeWidth * height) { + gesture = gesture?.getEdgeVariant(Gesture.Edge.TOP) + } else if (mainPointerPath.min.y > (1 - edgeWidth) * height) { + gesture = gesture?.getEdgeVariant(Gesture.Edge.BOTTOM) + } + } + gesture?.invoke(context) + } + } +} \ No newline at end of file diff --git a/app/src/main/res/values/defaults.xml b/app/src/main/res/values/defaults.xml index 276651d..cee201e 100644 --- a/app/src/main/res/values/defaults.xml +++ b/app/src/main/res/values/defaults.xml @@ -2,6 +2,8 @@ + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 97d7384..b61c1b0 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -59,6 +59,24 @@ Swipe down at the left edge of the screen Down (Right Edge) Swipe down at the right edge of the screen + + ]]> + Top left -> mid right -> bottom left + (reverse)]]> + Bottom left -> mid right -> top left + + Top right -> mid left -> bottom right + + Bottom right -> mid left -> top right + V + Top left -> bottom mid -> top right + V (reverse) + Top right -> bottom mid -> top left + Λ + Bottom left -> top mid -> bottom right + Λ (reverse) + Bottom right -> top mid -> bottom left + Volume Up Press the volume up button Volume Down diff --git a/BUILD.md b/docs/build.md similarity index 100% rename from BUILD.md rename to docs/build.md diff --git a/docs/launcher.md b/docs/launcher.md new file mode 100644 index 0000000..37b24a4 --- /dev/null +++ b/docs/launcher.md @@ -0,0 +1,49 @@ +# Notable changes compared to [Finn's Launcher][original-repo]: + +µLauncher is a fork of [finnmglas's app Launcher][original-repo]. +Here is an incomplete list of changes: + + + +- Additional gestures: + - Back + - V,Λ,<,> + - Edge gestures: There is a setting to allow distinguishing swiping at the edges of the screen from swiping in the center. +- Compatible with [work profile](https://www.android.com/enterprise/work-profile/), so apps like [Shelter](https://gitea.angry.im/PeterCxy/Shelter) can be used. +- Compatible with [private space](https://source.android.com/docs/security/features/private-space) +- Option to rename apps +- Option to hide apps +- Favorite apps +- New actions: + - Toggle Torch + - Lock screen +- The home button now works as expected. +- Improved gesture detection. + +### Visual +- This app uses the system wallpaper instead of a custom solution. +- The font has been changed to [Hack][hack-font], other fonts can be selected. +- Font Awesome Icons were replaced by Material icons. +- The gear button on the home screen was removed. A smaller button is show at the top right when necessary. + + +### 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 +- Improved 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) +- Fixed some bugs +- Some refactoring + + +The complete list of changes can be viewed [here](https://github.com/jrpie/launcher/compare/340ee731...master). + +--- + [original-repo]: https://github.com/finnmglas/Launcher