Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,14 @@
## [4.2.1](https://github.com/sds100/KeyMapper/releases/tag/v4.2.1)

#### 09 June 2026

## Fixed

- #2154 The expert mode debug screen is now only accessible after the expert mode warning has been acknowledged.
- #2156 Do not throw an error when the Talkback application can not be found because there are many different package names out there.
- #2153 Prevent Direct Boot startup from initializing credential-encrypted app storage before the user unlocks.
- #2157 The "choose setting" screen now uses `settings list` via the system bridge when expert mode is active, surfacing all device settings instead of only those visible through the ContentProvider.

## [4.2.0](https://github.com/sds100/KeyMapper/releases/tag/v4.2.0)

#### 03 June 2026
Expand Down
2 changes: 2 additions & 0 deletions app/proguard-rules.pro
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,7 @@
# android.content package classes
-keep class android.content.IContentProvider** { *; }
-keep class android.content.IIntentReceiver** { *; }
-keep class android.os.ICancellationSignal** { *; }

# android.content.pm package classes
-keep class android.content.pm.IPackageManager** { *; }
Expand Down Expand Up @@ -245,3 +246,4 @@
-dontwarn android.view.IWindowManager**
-dontwarn com.android.internal.app.**
-dontwarn com.android.internal.policy.**
-dontwarn android.os.ICancellationSignal
4 changes: 2 additions & 2 deletions app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,6 @@
<application
android:name="io.github.sds100.keymapper.KeyMapperApp"
android:allowBackup="true"
android:directBootAware="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
Expand Down Expand Up @@ -55,6 +54,7 @@
<service
android:name=".system.accessibility.MyAccessibilityService"
android:configChanges="orientation|screenSize"
android:directBootAware="false"
android:exported="true"
android:permission="android.permission.BIND_ACCESSIBILITY_SERVICE">
<intent-filter>
Expand All @@ -67,4 +67,4 @@
</service>

</application>
</manifest>
</manifest>
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
package io.github.sds100.keymapper.system.accessibility

import android.content.Intent
import android.os.UserManager
import android.view.KeyEvent
import android.view.accessibility.AccessibilityEvent
import androidx.core.content.getSystemService
import dagger.hilt.android.AndroidEntryPoint
import io.github.sds100.keymapper.base.system.accessibility.BaseAccessibilityService
import io.github.sds100.keymapper.base.system.accessibility.BaseAccessibilityServiceController
Expand All @@ -14,6 +18,9 @@ class MyAccessibilityService : BaseAccessibilityService() {
lateinit var controllerFactory: AccessibilityServiceController.Factory

private var controller: AccessibilityServiceController? = null
private var loggedLockedInitDelay = false

private val userManager: UserManager? by lazy { getSystemService<UserManager>() }

override fun getController(): BaseAccessibilityServiceController? {
return controller
Expand All @@ -22,15 +29,47 @@ class MyAccessibilityService : BaseAccessibilityService() {
override fun onServiceConnected() {
super.onServiceConnected()

initializeControllerIfUserUnlocked()
}

override fun onAccessibilityEvent(event: AccessibilityEvent?) {
if (!initializeControllerIfUserUnlocked()) {
return
}

super.onAccessibilityEvent(event)
}

override fun onKeyEvent(event: KeyEvent?): Boolean {
if (!initializeControllerIfUserUnlocked()) {
return false
}

return super.onKeyEvent(event)
}

private fun initializeControllerIfUserUnlocked(): Boolean {
if (userManager?.isUserUnlocked == false) {
if (!loggedLockedInitDelay) {
Timber.i("Accessibility service: Delay init because locked.")
loggedLockedInitDelay = true
}

return false
}

loggedLockedInitDelay = false

/*
I would put this in onCreate but for some reason on some devices getting the application
context would return null
*/
if (controller == null) {
controller = controllerFactory.create(this)
controller?.onServiceConnected()
}

controller?.onServiceConnected()
return true
}

override fun onUnbind(intent: Intent?): Boolean {
Expand Down
4 changes: 2 additions & 2 deletions app/version.properties
Original file line number Diff line number Diff line change
@@ -1,2 +1,2 @@
VERSION_NAME=4.2.0
VERSION_CODE=253
VERSION_NAME=4.2.1
VERSION_CODE=256
11 changes: 4 additions & 7 deletions base/src/main/assets/whats-new.txt
Original file line number Diff line number Diff line change
@@ -1,9 +1,6 @@
🎯 New Actions
• Select all text at the cursor
• Input on-screen keyboard enter/send button
• Show a toast message

🔧 Improvements
• Bug fixes
• Talkback actions
• Monochrome app icon
• "Any input device" is the default for triggers
• Bug fixes and accessibility improvements

📖 View the complete changelog at: http://keymapper.app/changelog
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import androidx.lifecycle.LifecycleObserver
import androidx.lifecycle.OnLifecycleEvent
import androidx.lifecycle.ProcessLifecycleOwner
import androidx.multidex.MultiDexApplication
import dagger.Lazy
import io.github.sds100.keymapper.base.expertmode.SystemBridgeAutoStarter
import io.github.sds100.keymapper.base.logging.KeyMapperLoggingTree
import io.github.sds100.keymapper.base.logging.SystemBridgeLogger
Expand Down Expand Up @@ -51,46 +52,46 @@ abstract class BaseKeyMapperApp : MultiDexApplication() {
private val tag = BaseKeyMapperApp::class.simpleName

@Inject
lateinit var appCoroutineScope: CoroutineScope
lateinit var appCoroutineScope: Lazy<CoroutineScope>

@Inject
lateinit var notificationController: NotificationController
lateinit var notificationController: Lazy<NotificationController>

@Inject
lateinit var packageManagerAdapter: AndroidPackageManagerAdapter
lateinit var packageManagerAdapter: Lazy<AndroidPackageManagerAdapter>

@Inject
lateinit var devicesAdapter: AndroidDevicesAdapter
lateinit var devicesAdapter: Lazy<AndroidDevicesAdapter>

@Inject
lateinit var permissionAdapter: AndroidPermissionAdapter
lateinit var permissionAdapter: Lazy<AndroidPermissionAdapter>

@Inject
lateinit var accessibilityServiceAdapter: AccessibilityServiceAdapterImpl
lateinit var accessibilityServiceAdapter: Lazy<AccessibilityServiceAdapterImpl>

@Inject
lateinit var autoGrantPermissionController: AutoGrantPermissionController
lateinit var autoGrantPermissionController: Lazy<AutoGrantPermissionController>

@Inject
lateinit var loggingTree: KeyMapperLoggingTree
lateinit var loggingTree: Lazy<KeyMapperLoggingTree>

@Inject
lateinit var settingsRepository: PreferenceRepositoryImpl
lateinit var settingsRepository: Lazy<PreferenceRepositoryImpl>

@Inject
lateinit var logRepository: LogRepository
lateinit var logRepository: Lazy<LogRepository>

@Inject
lateinit var keyEventRelayServiceWrapper: KeyEventRelayServiceWrapperImpl
lateinit var keyEventRelayServiceWrapper: Lazy<KeyEventRelayServiceWrapperImpl>

@Inject
lateinit var systemBridgeAutoStarter: SystemBridgeAutoStarter
lateinit var systemBridgeAutoStarter: Lazy<SystemBridgeAutoStarter>

@Inject
lateinit var systemBridgeConnectionManager: SystemBridgeConnectionManagerImpl
lateinit var systemBridgeConnectionManager: Lazy<SystemBridgeConnectionManagerImpl>

@Inject
lateinit var systemBridgeLogger: SystemBridgeLogger
lateinit var systemBridgeLogger: Lazy<SystemBridgeLogger>

private val processLifecycleOwner by lazy { ProcessLifecycleOwner.get() }

Expand Down Expand Up @@ -118,16 +119,18 @@ abstract class BaseKeyMapperApp : MultiDexApplication() {
Log.i(tag, "KeyMapperApp: OnCreate")

Thread.setDefaultUncaughtExceptionHandler { thread, exception ->
// log in a blocking manner and always log regardless of whether the setting is turned on
val entry = LogEntryEntity(
id = 0,
time = Calendar.getInstance().timeInMillis,
severity = LogEntryEntity.SEVERITY_ERROR,
message = exception.stackTraceToString(),
)

runBlocking {
logRepository.insertSuspend(entry)
if (userManager?.isUserUnlocked != false) {
// log in a blocking manner and always log regardless of whether the setting is turned on
val entry = LogEntryEntity(
id = 0,
time = Calendar.getInstance().timeInMillis,
severity = LogEntryEntity.SEVERITY_ERROR,
message = exception.stackTraceToString(),
)

runBlocking {
logRepository.get().insertSuspend(entry)
}
}

priorExceptionHandler?.uncaughtException(thread, exception)
Expand Down Expand Up @@ -168,7 +171,7 @@ abstract class BaseKeyMapperApp : MultiDexApplication() {

registerReceiver(broadcastReceiver, intentFilter)

settingsRepository.get(Keys.darkTheme)
settingsRepository.get().get(Keys.darkTheme)
.map { it?.toIntOrNull() }
.map {
when (it) {
Expand All @@ -178,35 +181,35 @@ abstract class BaseKeyMapperApp : MultiDexApplication() {
}
}
.onEach { mode -> AppCompatDelegate.setDefaultNightMode(mode) }
.launchIn(appCoroutineScope)
.launchIn(appCoroutineScope.get())

if (BuildConfig.BUILD_TYPE == "debug" || BuildConfig.BUILD_TYPE == "debug_release") {
Timber.plant(Timber.DebugTree())
}

Timber.plant(loggingTree)
Timber.plant(loggingTree.get())

notificationController.init()
notificationController.get().init()

processLifecycleOwner.lifecycle.addObserver(
object : LifecycleObserver {
@Suppress("DEPRECATION")
@OnLifecycleEvent(Lifecycle.Event.ON_RESUME)
fun onResume() {
// when the user returns to the app let everything know that the permissions could have changed
notificationController.onOpenApp()
notificationController.get().onOpenApp()

if (BuildConfig.DEBUG &&
permissionAdapter.isGranted(Permission.WRITE_SECURE_SETTINGS)
permissionAdapter.get().isGranted(Permission.WRITE_SECURE_SETTINGS)
) {
accessibilityServiceAdapter.start()
accessibilityServiceAdapter.get().start()
}
}
},
)

appCoroutineScope.launch {
notificationController.openApp.collectLatest { intentAction ->
appCoroutineScope.get().launch {
notificationController.get().openApp.collectLatest { intentAction ->
Intent(this@BaseKeyMapperApp, getMainActivityClass()).apply {
action = intentAction
flags = Intent.FLAG_ACTIVITY_NEW_TASK
Expand All @@ -216,38 +219,38 @@ abstract class BaseKeyMapperApp : MultiDexApplication() {
}
}

notificationController.showToast.onEach { toast ->
notificationController.get().showToast.onEach { toast ->
Toast.makeText(this, toast, Toast.LENGTH_SHORT).show()
}.launchIn(appCoroutineScope)
}.launchIn(appCoroutineScope.get())

autoGrantPermissionController.start()
keyEventRelayServiceWrapper.bind()
autoGrantPermissionController.get().start()
keyEventRelayServiceWrapper.get().bind()

if (systemBridgeConnectionManager.isConnected()) {
if (systemBridgeConnectionManager.get().isConnected()) {
Timber.i("KeyMapperApp: System bridge is connected")
} else {
Timber.i("KeyMapperApp: System bridge is disconnected")
}

systemBridgeAutoStarter.init()
systemBridgeAutoStarter.get().init()

// Initialize SystemBridgeLogger to start receiving log messages from SystemBridge.
// Using Lazy<> to avoid circular dependency issues and ensure it's only created
// when the API level requirement is met.
systemBridgeLogger.start()
systemBridgeLogger.get().start()

appCoroutineScope.launch {
systemBridgeConnectionManager.connectionState.collect { state ->
appCoroutineScope.get().launch {
systemBridgeConnectionManager.get().connectionState.collect { state ->
if (state is SystemBridgeConnectionState.Connected) {
val isUsed =
settingsRepository.get(Keys.isSystemBridgeUsed).first() ?: false
settingsRepository.get().get(Keys.isSystemBridgeUsed).first() ?: false

// Enable the setting to use PRO mode for key event actions the first time they use PRO mode.
if (!isUsed) {
settingsRepository.set(Keys.keyEventActionsUseSystemBridge, true)
settingsRepository.get().set(Keys.keyEventActionsUseSystemBridge, true)
}

settingsRepository.set(Keys.isSystemBridgeUsed, true)
settingsRepository.get().set(Keys.isSystemBridgeUsed, true)
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,13 @@ class BootBroadcastReceiver : BroadcastReceiver() {
Timber.i(
"Boot completed broadcast: time since boot = ${SystemClock.elapsedRealtime() / 1000}",
)
(context.applicationContext as? BaseKeyMapperApp)?.onBootUnlocked()
}

Intent.ACTION_LOCKED_BOOT_COMPLETED -> {
(context.applicationContext as? BaseKeyMapperApp)?.onBootUnlocked()
Timber.i(
"Locked boot completed broadcast: time since boot = ${SystemClock.elapsedRealtime() / 1000}",
)
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ class LazyActionErrorSnapshot(
cameraAdapter,
permissionAdapter,
) {

private val keyMapperImeHelper =
KeyMapperImeHelper(switchImeInterface, inputMethodAdapter, buildConfigProvider.packageName)

Expand Down Expand Up @@ -265,10 +266,6 @@ class LazyActionErrorSnapshot(
}
}

is ActionData.TalkBackGesture -> {
return getAppError(TALKBACK_PACKAGE_NAME)
}

else -> {}
}

Expand Down Expand Up @@ -321,5 +318,3 @@ interface ActionErrorSnapshot {
fun getError(action: ActionData): KMError?
fun getErrors(actions: List<ActionData>): Map<ActionData, KMError?>
}

private const val TALKBACK_PACKAGE_NAME = "com.google.android.marvin.talkback"
Loading
Loading