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].
-
@@ -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