commit 00e3cd71a896d0cacbf1a0416961a80538cd015f truex <truex@equwal.com> 2026-09-21 03:36:26 -0700 Carry the apps of other makers and cut the home and recents screens The build for direct install carries three release APKs, not changed, in app/src/full/assets/bundled, and installs them through the package installer of Android: CLauncher v5.3.0 (GPL-3.0), Ink Recents 0.1.1 (GPL-3.0) and Whisper 3.7 (MIT, the F-Droid build). NOTICE.txt names each maker, licence, source and SHA-256. Rebind downloads nothing. The Google Play build carries nothing, has no install permission, and opens the page of the maker. The home screen of Rebind and its settings are gone. CLauncher is the home screen. The recent-apps screen is gone too: it is now Ink Recents, a program of its own. RecentsActivity is a stand-in with no screen that opens Ink Recents, so that old bindings keep working. The app no longer asks for usage access or KILL_BACKGROUND_PROCESSES. Hardware hacks: the double tap of Power is one row with two ways in, the camera intent or the wallet intent. Channel titles name the gesture. The setup of a Power combination no longer says the same thing twice. Version 0.0.12-alpha, version code 12.
.gitignore | 2 + CHANGELOG.md | 26 +- README.md | 30 +- app/src/full/AndroidManifest.xml | 9 + app/src/full/assets/bundled/NOTICE.txt | 23 ++ .../assets/bundled/clauncher-v5.3.0-universal.apk | Bin 0 -> 2073428 bytes app/src/full/assets/bundled/ink-recents-0.1.1.apk | Bin 0 -> 2256640 bytes .../assets/bundled/org.woheller69.whisper_37.apk | Bin 0 -> 22741441 bytes .../java/dev/equwal/assistkey/bundle/Bundled.kt | 93 +++++ app/src/main/AndroidManifest.xml | 37 +- .../java/dev/equwal/assistkey/channel/Channel.kt | 12 +- .../main/java/dev/equwal/assistkey/home/Apps.kt | 173 ---------- .../java/dev/equwal/assistkey/home/HomeActivity.kt | 209 ------------ .../equwal/assistkey/home/HomeSettingsActivity.kt | 107 ------ .../dev/equwal/assistkey/home/RecentsActivity.kt | 378 +-------------------- .../equwal/assistkey/setup/GuidedSetupActivity.kt | 4 +- .../dev/equwal/assistkey/store/SettingsFile.kt | 1 - .../equwal/assistkey/ui/ActionPickerActivity.kt | 18 +- .../java/dev/equwal/assistkey/ui/BundledRows.kt | 28 ++ .../java/dev/equwal/assistkey/ui/HacksActivity.kt | 8 +- .../dev/equwal/assistkey/ui/HomeHubActivity.kt | 28 +- .../java/dev/equwal/assistkey/ui/MainActivity.kt | 16 +- .../dev/equwal/assistkey/ui/PermissionsActivity.kt | 9 +- .../main/java/dev/equwal/assistkey/ui/Summary.kt | 10 +- .../java/dev/equwal/assistkey/ui/VoiceActivity.kt | 14 +- .../java/dev/equwal/assistkey/bundle/Bundled.kt | 49 +++ .../dev/equwal/assistkey/home/RecentsOpenTest.kt | 30 -- .../java/dev/equwal/assistkey/ui/SummaryTest.kt | 6 +- gradle.properties | 4 +- play/DECLARATIONS.md | 3 +- play/LISTING.md | 5 +- play/PRIVACY.md | 7 +- 32 files changed, 310 insertions(+), 1029 deletions(-)
diff --git a/.gitignore b/.gitignore index 844ebd5..7940bdc 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,8 @@ build/ .gradle/ *.apk +# The unchanged release APKs of other makers that the full build carries. +!app/src/full/assets/bundled/*.apk *.aab *.ap_ *.dex diff --git a/CHANGELOG.md b/CHANGELOG.md index 899411d..7659600 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,30 @@ # Changelog -## Unreleased +## 0.0.12-alpha - 2026-09-21 + +Version code 12. + +- **Apps of other makers.** The build for direct install carries two apps and + can install them: CLauncher v5.3.0 (the release APK of its maker, not + changed) and Ink Recents 0.1.1 (open source). Home and recents lists them. Android asks before + each install. The APKs are in the build, because Rebind has no internet + permission. The Google Play build carries nothing and has no install + permission: Google Play does not allow that here. It opens the page of the + maker. +- Whisper 3.7 (speech to text on the device, MIT, the F-Droid build) is the + third app that the build for direct install carries. Voice typing lists it. +- **The home screen of Rebind is gone**, with its settings screens. CLauncher + is the home screen now, as its maker released it. An old settings file that + has home screen settings still imports. Those settings are skipped. +- **Recent apps is a program of its own: Ink Recents** (open source, + github.com/equwal/ink-recents). Rebind no longer has that screen, and no + longer asks for usage access or `KILL_BACKGROUND_PROCESSES`. The Recent apps + action opens Ink Recents. Old bindings keep working. Where Ink Recents is + not installed, the action opens the screen that offers it. +- Hardware hacks: the double tap of Power is one row. Its way in is the camera + intent or the wallet intent, as the device decides. There is no wallet + button. +- The setup of a Power combination no longer says the same thing twice. - **Hardware hacks** is a tile on the main screen with a screen of its own: hold Power (assistant role), double tap Power (camera intent), the wallet diff --git a/README.md b/README.md index 543f3fd..04997e0 100644 --- a/README.md +++ b/README.md @@ -5,8 +5,8 @@ Formerly AssistKey. The package id, the repository and the code keep the old nam Remaps the hardware keys of an Android device - volume keys, the Power button, and whatever else the device has - to arbitrary actions, with multi-tap, press-and-hold and key combinations. Also: navigation in any mix of button bar, -gestures and the Power key; a frontlight level below the system's floor; a -plain text home screen and recent-apps list made for e-ink. +gestures and the Power key; a frontlight level below the system's floor; and a +recent-apps list made for e-ink. It runs on any Android 12+ device. The **Viwoods AiPaper Reader** is the first device profile, because that is where it was built; see *Device profiles*. @@ -154,16 +154,21 @@ The Viwoods voice prompt is not reusable. It uploads audio to the Viwoods cloud Debug builds carry `FakeRecognitionService`, which hears a fixed sentence, so the whole chain can be tested on a bench with no voice. -## Home screen and recent apps +## Home, recent apps, speech: apps of other makers -`home/`. The home screen is a clock, a few chosen apps and a search line; the -app list is hidden until you type, and a single match opens itself. It is -original code: the idea is shared with CLauncher/Olauncher, which are GPL-3.0 -and therefore cannot be copied into this app. Disabled in the manifest until -switched on. Recent apps is a row of cards like the Android switcher, drawn for -e-ink: outlines, no animation, one jump per card. A card closes with a swipe up -when there is shell access, and shows the task picture the system keeps when -the shell is root (`/data/system_ce/0/snapshots`, owned by `system`). +Rebind has no recent-apps screen of its own. That screen is a program of its +own, open source: [Ink Recents](https://github.com/equwal/ink-recents). The +Recent apps action opens it (`dev.equwal.inkrecents.OPEN`). Where it is not +installed, the action opens the screen that offers it. `home/RecentsActivity` +is only that stand-in, and keeps the class name that old bindings hold. + +Rebind has no home screen of its own. The `full` build carries the release +APKs of other makers, not changed, in `app/src/full/assets/bundled/`, and +installs them with `REQUEST_INSTALL_PACKAGES`: CLauncher (home screen, +GPL-3.0), Ink Recents (GPL-3.0) and Whisper (speech to text, MIT). +`NOTICE.txt` in that folder names each maker, licence, source and SHA-256. +Android asks the user before each install. The `play` build carries nothing +and has no install permission. It opens the page of the maker. ## Why it is shaped like this @@ -432,7 +437,8 @@ engine/ the gesture state machine and the accessibility service channel/ the key filter switch and the Power side doors (assistant, camera, wallet) device/ device profiles display/ the extra-dim light -home/ the home screen and the recent-apps cards +home/ the stand-in that opens Ink Recents +bundle/ apps of other makers that the full build carries (per flavour) menu/ the menu of actions shell/ shell access through Shizuku, and the managed Power button voice/ voice typing: recognizer choice, the listening screen, text insert diff --git a/app/src/full/AndroidManifest.xml b/app/src/full/AndroidManifest.xml index 597e623..5ade5cc 100644 --- a/app/src/full/AndroidManifest.xml +++ b/app/src/full/AndroidManifest.xml @@ -2,11 +2,20 @@ <!-- The `full` flavour only: everything Shizuku needs. The `play` flavour has none of it. --> <manifest xmlns:android="http://schemas.android.com/apk/res/android"> + <!-- Installs the apps of other makers that this build carries in its assets. + Android asks the user before each install. Not in the Google Play build. --> + <uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" /> + <queries> <package android:name="moe.shizuku.privileged.api" /> </queries> <application> + <!-- The package installer of Android answers an install request here. --> + <receiver + android:name="dev.equwal.assistkey.bundle.Bundled$Result" + android:exported="false" /> + <!-- How Shizuku hands this app its binder. --> <provider android:name="rikka.shizuku.ShizukuProvider" diff --git a/app/src/full/assets/bundled/NOTICE.txt b/app/src/full/assets/bundled/NOTICE.txt new file mode 100644 index 0000000..0b4bbc0 --- /dev/null +++ b/app/src/full/assets/bundled/NOTICE.txt @@ -0,0 +1,23 @@ +Apps of other makers in this folder + +Each file is the release APK of its maker, not changed in any way. It is a +separate program. Rebind only hands it to the package installer of Android. + +clauncher-v5.3.0-universal.apk + CLauncher v5.3.0, by mlm-games + Licence: GNU General Public License v3.0 + Source code and releases: https://github.com/mlm-games/CLauncher + SHA-256: aedb78216822d5c8924eb125529f2dfa9140e4841b87ed309eca8f12f38956eb + +ink-recents-0.1.1.apk + Ink Recents 0.1.1, by equwal + Licence: GNU General Public License v3.0 or later + Source code and releases: https://github.com/equwal/ink-recents + SHA-256: f524150a25d7980224b93948133c5abee7c7bb4af3de9d7e721162fca5dd180b + +org.woheller69.whisper_37.apk + Whisper 3.7, by woheller69, as F-Droid built and signed it + Licence: MIT + Source code: https://github.com/woheller69/whisperIME + Release: https://f-droid.org/packages/org.woheller69.whisper/ + SHA-256: b7efb1f4377c7ecf870688b1f7d74ad201bff2bd8991a387316db3ab71353824 diff --git a/app/src/full/assets/bundled/clauncher-v5.3.0-universal.apk b/app/src/full/assets/bundled/clauncher-v5.3.0-universal.apk new file mode 100644 index 0000000..c5051cd Binary files /dev/null and b/app/src/full/assets/bundled/clauncher-v5.3.0-universal.apk differ diff --git a/app/src/full/assets/bundled/ink-recents-0.1.1.apk b/app/src/full/assets/bundled/ink-recents-0.1.1.apk new file mode 100644 index 0000000..41bafc6 Binary files /dev/null and b/app/src/full/assets/bundled/ink-recents-0.1.1.apk differ diff --git a/app/src/full/assets/bundled/org.woheller69.whisper_37.apk b/app/src/full/assets/bundled/org.woheller69.whisper_37.apk new file mode 100644 index 0000000..e908ca2 Binary files /dev/null and b/app/src/full/assets/bundled/org.woheller69.whisper_37.apk differ diff --git a/app/src/full/java/dev/equwal/assistkey/bundle/Bundled.kt b/app/src/full/java/dev/equwal/assistkey/bundle/Bundled.kt new file mode 100644 index 0000000..d46ab37 --- /dev/null +++ b/app/src/full/java/dev/equwal/assistkey/bundle/Bundled.kt @@ -0,0 +1,93 @@ +package dev.equwal.assistkey.bundle + +import android.app.Activity +import android.app.PendingIntent +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.pm.PackageInstaller +import android.widget.Toast + +/** + * Apps of other makers that this build carries and can install. + * + * Each app is the APK that its maker released, byte for byte, in the assets of + * this build. Rebind does not change it and does not download it: this app has + * no internet permission. Android asks the user before each install. + * + * Only the build for direct install has this. Google Play does not allow an + * app to install other apps unless that is its main purpose. + */ +object Bundled { + + const val SUPPORTED = true + + class Item( + /** The screen that lists the app: HOME or VOICE. */ + val group: String, + val title: String, + val hint: String, + val pkg: String, + val version: String, + val licence: String, + /** Where the maker publishes the source code and the releases. */ + val source: String, + internal val asset: String + ) + + const val HOME = "home" + const val VOICE = "voice" + + val items = listOf( + Item( + HOME, "CLauncher", "Minimal home screen", "app.clauncher", "v5.3.0", "GPL-3.0", + "https://github.com/mlm-games/CLauncher", "bundled/clauncher-v5.3.0-universal.apk" + ), + Item( + HOME, "Ink Recents", "Recent apps, drawn for e-ink", "dev.equwal.inkrecents", "0.1.1", "GPL-3.0", + "https://github.com/equwal/ink-recents", "bundled/ink-recents-0.1.1.apk" + ), + Item( + VOICE, "Whisper", "Speech to text on the device", "org.woheller69.whisper", "3.7", "MIT", + "https://f-droid.org/packages/org.woheller69.whisper/", "bundled/org.woheller69.whisper_37.apk" + ) + ) + + /** Hands the APK to the package installer of Android. Android then asks the user. */ + fun install(a: Activity, item: Item) { + runCatching { + val installer = a.packageManager.packageInstaller + val params = PackageInstaller.SessionParams(PackageInstaller.SessionParams.MODE_FULL_INSTALL) + params.setAppPackageName(item.pkg) + val id = installer.createSession(params) + installer.openSession(id).use { session -> + a.assets.open(item.asset).use { input -> + session.openWrite("base.apk", 0, -1).use { out -> + input.copyTo(out) + session.fsync(out) + } + } + val result = PendingIntent.getBroadcast( + a, id, Intent(a, Result::class.java), + PendingIntent.FLAG_MUTABLE or PendingIntent.FLAG_UPDATE_CURRENT + ) + session.commit(result.intentSender) + } + }.onFailure { Toast.makeText(a, "Could not start the install", Toast.LENGTH_LONG).show() } + } + + /** Android answers here: first with the question for the user, then with the result. */ + class Result : BroadcastReceiver() { + override fun onReceive(c: Context, i: Intent) { + when (i.getIntExtra(PackageInstaller.EXTRA_STATUS, PackageInstaller.STATUS_FAILURE)) { + PackageInstaller.STATUS_PENDING_USER_ACTION -> { + @Suppress("DEPRECATION") + val ask = i.getParcelableExtra<Intent>(Intent.EXTRA_INTENT) ?: return + runCatching { c.startActivity(ask.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)) } + } + PackageInstaller.STATUS_SUCCESS -> Toast.makeText(c, "Installed", Toast.LENGTH_SHORT).show() + else -> Toast.makeText(c, "Not installed", Toast.LENGTH_LONG).show() + } + } + } +} diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 1385157..3727bb5 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -10,22 +10,12 @@ <uses-permission android:name="android.permission.WRITE_SECURE_SETTINGS" tools:ignore="ProtectedPermissions" /> - <!-- - Special access the user grants from a settings screen. Only the recent - apps list uses it, and only when there is no shell access to ask the - system directly. - --> - <uses-permission android:name="android.permission.PACKAGE_USAGE_STATS" - tools:ignore="ProtectedPermissions" /> <!-- Voice typing only. The speech recognition app does the recording; Android still requires the app that asks for it to hold the permission. Requested at run time, from the Voice typing screen, and never otherwise. --> - <!-- Recent apps: "close" ends the background processes of an app where - there is no shell access. A normal permission: Android grants it at install. --> - <uses-permission android:name="android.permission.KILL_BACKGROUND_PROCESSES" /> <uses-permission android:name="android.permission.RECORD_AUDIO" /> @@ -49,6 +39,8 @@ <intent> <action android:name="android.speech.RecognitionService" /> </intent> + <!-- Ink Recents, which the Recent apps action opens. --> + <package android:name="dev.equwal.inkrecents" /> <package android:name="com.viwoods.launcher" /> <package android:name="com.viwoods.viwoodsai" /> <package android:name="com.viwoods.settings" /> @@ -113,33 +105,14 @@ android:launchMode="singleInstance" android:taskAffinity="" android:theme="@style/Theme.Invisible" /> - <activity android:name=".home.HomeSettingsActivity" android:exported="false" /> <activity android:name=".home.RecentsActivity" android:exported="false" android:excludeFromRecents="true" - android:launchMode="singleTask" - android:taskAffinity="dev.equwal.assistkey.recents" /> + android:noHistory="true" + android:taskAffinity="" + android:theme="@style/Theme.Invisible" /> - <!-- - The home screen. Disabled until the user asks for it, so the app is - not a launcher candidate on anybody's device by default. - --> - <activity - android:name=".home.HomeActivity" - android:enabled="false" - android:exported="true" - android:excludeFromRecents="true" - android:launchMode="singleTask" - android:stateNotNeeded="true" - android:taskAffinity="dev.equwal.assistkey.home" - android:windowSoftInputMode="stateHidden|adjustResize"> - <intent-filter> - <action android:name="android.intent.action.MAIN" /> - <category android:name="android.intent.category.HOME" /> - <category android:name="android.intent.category.DEFAULT" /> - </intent-filter> - </activity> <activity android:name=".ui.ReportActivity" android:exported="false" /> <activity android:name=".ui.DetectActivity" android:exported="false" /> diff --git a/app/src/main/java/dev/equwal/assistkey/channel/Channel.kt b/app/src/main/java/dev/equwal/assistkey/channel/Channel.kt index 06326c2..8714ee9 100644 --- a/app/src/main/java/dev/equwal/assistkey/channel/Channel.kt +++ b/app/src/main/java/dev/equwal/assistkey/channel/Channel.kt @@ -32,22 +32,22 @@ enum class Channel( ASSISTANT( "assistant", - "Digital assistant", - "Catches a held Power button", + "Hold Power", + "Through the assistant role", listOf("dev.equwal.assistkey.channel.AssistActivity") ), CAMERA( "camera", - "Camera app", - "Catches a double press of Power", + "Double tap Power, by camera", + "Most devices send it to the camera", listOf("dev.equwal.assistkey.channel.CameraShimActivity") ), WALLET( "wallet", - "Wallet app", - "Catches the wallet tile and button", + "Double tap Power, by wallet", + "Some devices send it to the wallet", listOf( "dev.equwal.assistkey.channel.WalletActivity", "dev.equwal.assistkey.channel.WalletService" diff --git a/app/src/main/java/dev/equwal/assistkey/home/Apps.kt b/app/src/main/java/dev/equwal/assistkey/home/Apps.kt deleted file mode 100644 index 3ccaa35..0000000 --- a/app/src/main/java/dev/equwal/assistkey/home/Apps.kt +++ /dev/null @@ -1,173 +0,0 @@ -package dev.equwal.assistkey.home - -import android.app.usage.UsageEvents -import android.app.usage.UsageStatsManager -import android.content.ComponentName -import android.content.Context -import android.content.Intent -import android.content.pm.PackageManager -import android.os.Process -import dev.equwal.assistkey.shell.Shell - -/** The apps on this device, as the home screen and the recents list need them. */ -object Apps { - - data class App(val label: String, val pkg: String, val activity: String) { - val component: ComponentName get() = ComponentName(pkg, activity) - } - - private const val PREFS = "assistkey_home" - private const val K_FAVS = "favourites" // ordered, newline separated packages - private const val K_HIDDEN = "hidden" - private const val K_CLOCK = "clock" - private const val K_AUTO = "auto_open" - - private fun prefs(c: Context) = c.getSharedPreferences(PREFS, Context.MODE_PRIVATE) - - /** Everything with a launcher icon, alphabetical, ourselves included. */ - fun all(c: Context): List<App> { - val pm = c.packageManager - val main = Intent(Intent.ACTION_MAIN).addCategory(Intent.CATEGORY_LAUNCHER) - return pm.queryIntentActivities(main, 0) - .map { App(it.loadLabel(pm).toString(), it.activityInfo.packageName, it.activityInfo.name) } - .distinctBy { it.pkg + "/" + it.activity } - .sortedBy { it.label.lowercase() } - } - - fun visible(c: Context): List<App> = hidden(c).let { h -> all(c).filter { it.pkg !in h } } - - fun launch(c: Context, app: App): Boolean = runCatching { - c.startActivity( - Intent(Intent.ACTION_MAIN).addCategory(Intent.CATEGORY_LAUNCHER) - .setComponent(app.component) - .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_RESET_TASK_IF_NEEDED) - ) - true - }.getOrDefault(false) - - // ---- preferences ------------------------------------------------------------------------- - - fun favourites(c: Context): List<String> = - prefs(c).getString(K_FAVS, "").orEmpty().lines().filter { it.isNotBlank() } - - fun setFavourites(c: Context, pkgs: List<String>) { - prefs(c).edit().putString(K_FAVS, pkgs.distinct().joinToString("\n")).apply() - } - - fun toggleFavourite(c: Context, pkg: String) { - val f = favourites(c) - setFavourites(c, if (pkg in f) f - pkg else f + pkg) - } - - fun hidden(c: Context): Set<String> = prefs(c).getStringSet(K_HIDDEN, emptySet()).orEmpty() - - fun toggleHidden(c: Context, pkg: String) { - val h = hidden(c) - prefs(c).edit().putStringSet(K_HIDDEN, if (pkg in h) h - pkg else h + pkg).apply() - } - - fun showClock(c: Context): Boolean = prefs(c).getBoolean(K_CLOCK, true) - fun setShowClock(c: Context, on: Boolean) = prefs(c).edit().putBoolean(K_CLOCK, on).apply() - - /** Open the app by itself once typing has narrowed the list to one. */ - fun autoOpen(c: Context): Boolean = prefs(c).getBoolean(K_AUTO, true) - fun setAutoOpen(c: Context, on: Boolean) = prefs(c).edit().putBoolean(K_AUTO, on).apply() - - // ---- recents ----------------------------------------------------------------------------- - - fun hasUsageAccess(c: Context): Boolean { - val ops = c.getSystemService(android.app.AppOpsManager::class.java) ?: return false - return ops.unsafeCheckOpNoThrow( - android.app.AppOpsManager.OPSTR_GET_USAGE_STATS, Process.myUid(), c.packageName - ) == android.app.AppOpsManager.MODE_ALLOWED - } - - /** Most recent first, from the usage log. Needs the user's usage-access grant. */ - fun recentFromUsage(c: Context, limit: Int = 12): List<App> { - val usm = c.getSystemService(UsageStatsManager::class.java) ?: return emptyList() - val now = System.currentTimeMillis() - val events = usm.queryEvents(now - 3L * 24 * 60 * 60 * 1000, now) - val last = LinkedHashMap<String, Long>() - val e = UsageEvents.Event() - while (events.hasNextEvent()) { - events.getNextEvent(e) - if (e.eventType == UsageEvents.Event.ACTIVITY_RESUMED) last[e.packageName] = e.timeStamp - } - return rank(c, open(last, closedAt(c)), limit) - } - - /** - * The packages to show, most recent first. An app the user closed stays - * off the list until the user opens that app again. - */ - fun open(lastUsed: Map<String, Long>, closedAt: Map<String, Long>): List<String> = - lastUsed.entries - .filter { (pkg, at) -> at > (closedAt[pkg] ?: Long.MIN_VALUE) } - .sortedByDescending { it.value } - .map { it.key } - - private const val RECENTS_PREFS = "assistkey_recents" - - private fun closedAt(c: Context): Map<String, Long> = - c.getSharedPreferences(RECENTS_PREFS, Context.MODE_PRIVATE).all - .mapNotNull { (pkg, at) -> (at as? Long)?.let { pkg to it } }.toMap() - - /** - * Closes [pkg] without shell access: Android ends the background processes - * of the app, and the app leaves the list of this screen. - */ - fun closeWithoutShell(c: Context, pkg: String) { - if (pkg == c.packageName) return - runCatching { c.getSystemService(android.app.ActivityManager::class.java)?.killBackgroundProcesses(pkg) } - c.getSharedPreferences(RECENTS_PREFS, Context.MODE_PRIVATE).edit() - .putLong(pkg, System.currentTimeMillis()).apply() - } - - /** One entry of the recent-apps list. [taskId] is known only with shell access. */ - data class Recent(val app: App, val taskId: Int?) - - /** - * Most recent first, from the real task list of the system. Needs shell - * access. A task line names its app as `A=<uid>:<package>` or, for some - * tasks, as `I=<package>/<class>`. - */ - fun recentFromShell(c: Context, limit: Int = 12, done: (List<Recent>?) -> Unit) { - if (!Shell.ready) return done(null) - Shell.run("dumpsys activity recents") { r -> - if (!r.ok) return@run done(null) - val task = Regex("""Recent #\d+: Task\{\S+ #(\d+) type=(\w+) (?:A=\d+:|I=)([^\s/}]+)""") - val ids = LinkedHashMap<String, Int>() - task.findAll(r.output) - .filter { it.groupValues[2] == "standard" } - .forEach { ids.putIfAbsent(it.groupValues[3], it.groupValues[1].toInt()) } - done(rank(c, ids.keys.toList(), limit).map { Recent(it, ids[it.pkg]) }) - } - } - - /** - * The picture the system keeps of a task. The files belong to `system`, so - * this works only where the shell is root, or may use `su`. Null otherwise. - */ - fun snapshot(taskId: Int, done: (android.graphics.Bitmap?) -> Unit) { - val dir = "/data/system_ce/0/snapshots/" - val pick = "f=$dir${taskId}_reduced.jpg; [ -e \$f ] || f=$dir$taskId.jpg; " - Shell.run(pick + "base64 -w 0 \$f 2>/dev/null || su 0 base64 -w 0 \$f 2>/dev/null") { r -> - val bytes = runCatching { android.util.Base64.decode(r.output.trim(), android.util.Base64.DEFAULT) }.getOrNull() - done(bytes?.takeIf { it.size > 100 }?.let { android.graphics.BitmapFactory.decodeByteArray(it, 0, it.size) }) - } - } - - private fun rank(c: Context, pkgs: List<String>, limit: Int): List<App> { - val byPkg = all(c).associateBy { it.pkg } - val home = homePackages(c) - return pkgs.distinct() - .filter { it != c.packageName && it !in home } - .mapNotNull { byPkg[it] } - .take(limit) - } - - private fun homePackages(c: Context): Set<String> = - c.packageManager.queryIntentActivities( - Intent(Intent.ACTION_MAIN).addCategory(Intent.CATEGORY_HOME), PackageManager.MATCH_DEFAULT_ONLY - ).map { it.activityInfo.packageName }.toSet() -} diff --git a/app/src/main/java/dev/equwal/assistkey/home/HomeActivity.kt b/app/src/main/java/dev/equwal/assistkey/home/HomeActivity.kt deleted file mode 100644 index 7f6b8d7..0000000 --- a/app/src/main/java/dev/equwal/assistkey/home/HomeActivity.kt +++ /dev/null @@ -1,209 +0,0 @@ -package dev.equwal.assistkey.home - -import android.app.Activity -import android.app.AlertDialog -import android.content.Intent -import android.graphics.Color -import android.graphics.Typeface -import android.net.Uri -import android.os.Bundle -import android.provider.Settings -import android.text.Editable -import android.text.InputType -import android.text.TextWatcher -import android.util.TypedValue -import android.view.Gravity -import android.view.ViewGroup.LayoutParams.MATCH_PARENT -import android.view.ViewGroup.LayoutParams.WRAP_CONTENT -import android.view.WindowInsets -import android.view.inputmethod.EditorInfo -import android.view.inputmethod.InputMethodManager -import android.widget.EditText -import android.widget.LinearLayout -import android.widget.ScrollView -import android.widget.TextClock -import android.widget.TextView -import dev.equwal.assistkey.ui.Ui.dp - -/** - * A home screen with nothing on it. - * - * A clock, the handful of apps you chose, and a line to type in. The full app - * list is never shown: type a few letters and the matches appear, and when only - * one is left it opens by itself. On e-ink that is also the fastest design - * there is - no icons to draw, no grid to scroll, nothing that animates. - * - * Original code. The idea of a launcher that hides everything until you type is - * shared with several open-source launchers; none of their code is here. - */ -class HomeActivity : Activity() { - - private lateinit var results: LinearLayout - private lateinit var favourites: LinearLayout - private lateinit var search: EditText - private var apps: List<Apps.App> = emptyList() - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - build() - } - - override fun onResume() { - super.onResume() - apps = Apps.visible(this) - search.setText("") - showFavourites() - } - - /** Home is the bottom of the stack. Back has nowhere to go. */ - @Deprecated("Deprecated in Java") - override fun onBackPressed() { - if (search.text.isNotEmpty()) search.setText("") - } - - override fun onNewIntent(intent: Intent?) { - super.onNewIntent(intent) - // Home pressed while already home: clear the search. - if (::search.isInitialized) search.setText("") - } - - // ---- layout --------------------------------------------------------------------------------- - - private fun build() { - val col = LinearLayout(this).apply { - orientation = LinearLayout.VERTICAL - val p = dp(28) - setPadding(p, dp(36), p, p) - } - - if (Apps.showClock(this)) { - col.addView(TextClock(this).apply { - format12Hour = "h:mm" - format24Hour = "H:mm" - setTextColor(Color.BLACK) - setTextSize(TypedValue.COMPLEX_UNIT_SP, 64f) - typeface = Typeface.create("sans-serif-light", Typeface.NORMAL) - setOnClickListener { open(Intent(android.provider.AlarmClock.ACTION_SHOW_ALARMS)) } - }) - col.addView(TextClock(this).apply { - format12Hour = "EEEE, d MMMM" - format24Hour = "EEEE, d MMMM" - setTextColor(Color.rgb(70, 70, 70)) - setTextSize(TypedValue.COMPLEX_UNIT_SP, 18f) - setPadding(0, 0, 0, dp(28)) - }) - } - - favourites = LinearLayout(this).apply { orientation = LinearLayout.VERTICAL } - col.addView(favourites) - - search = EditText(this).apply { - hint = "Type to find an app" - setSingleLine() - inputType = InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_FLAG_NO_SUGGESTIONS - imeOptions = EditorInfo.IME_ACTION_GO - setTextSize(TypedValue.COMPLEX_UNIT_SP, 20f) - setTextColor(Color.BLACK) - setPadding(0, dp(20), 0, dp(12)) - addTextChangedListener(object : TextWatcher { - override fun afterTextChanged(s: Editable?) = filter(s?.toString().orEmpty()) - override fun beforeTextChanged(s: CharSequence?, a: Int, b: Int, c: Int) = Unit - override fun onTextChanged(s: CharSequence?, a: Int, b: Int, c: Int) = Unit - }) - setOnEditorActionListener { _, _, _ -> matches(text.toString()).firstOrNull()?.let(::launch); true } - } - col.addView(search, LinearLayout.LayoutParams(MATCH_PARENT, WRAP_CONTENT)) - - results = LinearLayout(this).apply { orientation = LinearLayout.VERTICAL } - col.addView(results) - - val scroll = ScrollView(this).apply { - setBackgroundColor(Color.WHITE) - isFillViewport = true - clipToPadding = false - addView(col, LinearLayout.LayoutParams(MATCH_PARENT, WRAP_CONTENT)) - setOnApplyWindowInsetsListener { v, insets -> - val bars = insets.getInsets(WindowInsets.Type.systemBars() or WindowInsets.Type.ime()) - v.setPadding(bars.left, bars.top, bars.right, bars.bottom) - insets - } - // Long-press on empty space is the only way into settings: there is - // deliberately no button for it. - setOnLongClickListener { startActivity(Intent(this@HomeActivity, HomeSettingsActivity::class.java)); true } - } - col.setOnLongClickListener { startActivity(Intent(this, HomeSettingsActivity::class.java)); true } - setContentView(scroll) - } - - private fun line(app: Apps.App, size: Float): TextView = TextView(this).apply { - text = app.label - setTextColor(Color.BLACK) - setTextSize(TypedValue.COMPLEX_UNIT_SP, size) - gravity = Gravity.START - setPadding(0, dp(12), 0, dp(12)) - setOnClickListener { launch(app) } - setOnLongClickListener { menu(app); true } - } - - // ---- behaviour ----------------------------------------------------------------------------- - - private fun showFavourites() { - favourites.removeAllViews() - val byPkg = apps.associateBy { it.pkg } - val favs = Apps.favourites(this).mapNotNull { byPkg[it] } - favs.forEach { favourites.addView(line(it, 26f)) } - if (favs.isEmpty()) { - favourites.addView(TextView(this).apply { - text = "Find an app below, then press and hold it to keep it here." - setTextColor(Color.rgb(90, 90, 90)) - setTextSize(TypedValue.COMPLEX_UNIT_SP, 15f) - }) - } - } - - private fun matches(q: String): List<Apps.App> { - val needle = q.trim().lowercase() - if (needle.isEmpty()) return emptyList() - // Starts-with first, then anywhere in the name, then initials ("gm" finds Google Maps). - val starts = apps.filter { it.label.lowercase().startsWith(needle) } - val inside = apps.filter { needle in it.label.lowercase() } - starts.toSet() - val initials = apps.filter { a -> - a.label.split(' ', '-', '_').mapNotNull { it.firstOrNull()?.lowercaseChar() }.joinToString("").startsWith(needle) - } - starts.toSet() - inside.toSet() - return starts + inside + initials - } - - private fun filter(q: String) { - results.removeAllViews() - val found = matches(q) - if (found.size == 1 && q.trim().length >= 2 && Apps.autoOpen(this)) { - launch(found[0]) - return - } - found.take(12).forEach { results.addView(line(it, 22f)) } - } - - private fun launch(app: Apps.App) { - getSystemService(InputMethodManager::class.java)?.hideSoftInputFromWindow(search.windowToken, 0) - Apps.launch(this, app) - } - - private fun open(i: Intent) { - runCatching { startActivity(i.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)) } - } - - private fun menu(app: Apps.App) { - val fav = app.pkg in Apps.favourites(this) - val items = arrayOf(if (fav) "Remove from home" else "Keep on home", "Hide from search", "App info") - AlertDialog.Builder(this).setTitle(app.label).setItems(items) { _, which -> - when (which) { - 0 -> Apps.toggleFavourite(this, app.pkg) - 1 -> Apps.toggleHidden(this, app.pkg) - 2 -> open(Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS, Uri.parse("package:" + app.pkg))) - } - apps = Apps.visible(this) - search.setText("") - showFavourites() - }.show() - } -} diff --git a/app/src/main/java/dev/equwal/assistkey/home/HomeSettingsActivity.kt b/app/src/main/java/dev/equwal/assistkey/home/HomeSettingsActivity.kt deleted file mode 100644 index fbecb26..0000000 --- a/app/src/main/java/dev/equwal/assistkey/home/HomeSettingsActivity.kt +++ /dev/null @@ -1,107 +0,0 @@ -package dev.equwal.assistkey.home - -import android.app.Activity -import android.app.role.RoleManager -import android.content.ComponentName -import android.content.Intent -import android.content.pm.PackageManager -import android.provider.Settings -import dev.equwal.assistkey.ui.Ui -import dev.equwal.assistkey.ui.Ui.button -import dev.equwal.assistkey.ui.Ui.check -import dev.equwal.assistkey.ui.Ui.header -import dev.equwal.assistkey.ui.Ui.note -import dev.equwal.assistkey.ui.Ui.primaryButton -import dev.equwal.assistkey.ui.Ui.row - -/** - * Switching the AssistKey home screen on, and tuning it. - * - * The home activity is disabled in the manifest. Until the user asks for it - * here the app is not a launcher and never shows up as one. - */ -class HomeSettingsActivity : Activity() { - - private val home by lazy { ComponentName(this, HomeActivity::class.java) } - - override fun onResume() { - super.onResume() - build() - } - - private fun enabled(): Boolean = - packageManager.getComponentEnabledSetting(home) == PackageManager.COMPONENT_ENABLED_STATE_ENABLED - - private fun isDefault(): Boolean { - val res = packageManager.resolveActivity( - Intent(Intent.ACTION_MAIN).addCategory(Intent.CATEGORY_HOME), PackageManager.MATCH_DEFAULT_ONLY - ) - return res?.activityInfo?.packageName == packageName - } - - private fun build() { - val col = Ui.page(this, "Home screen") - - col.check("Offer Rebind as a home screen", null, enabled()) { on -> - packageManager.setComponentEnabledSetting( - home, - if (on) PackageManager.COMPONENT_ENABLED_STATE_ENABLED - else PackageManager.COMPONENT_ENABLED_STATE_DISABLED, - PackageManager.DONT_KILL_APP - ) - build() - } - if (!enabled()) return - - col.row( - "Home screen", - null, - enabled = false, - state = if (isDefault()) "In use" else "Ready" - ) - if (!isDefault()) { - col.primaryButton("Make it the home screen") { - val rm = getSystemService(RoleManager::class.java) - val i = if (rm != null && rm.isRoleAvailable(RoleManager.ROLE_HOME)) { - rm.createRequestRoleIntent(RoleManager.ROLE_HOME) - } else Intent(Settings.ACTION_HOME_SETTINGS) - runCatching { startActivityForResult(i, 1) } - .onFailure { runCatching { startActivity(Intent(Settings.ACTION_HOME_SETTINGS)) } } - } - } else { - col.button("Choose a different home screen") { - runCatching { startActivity(Intent(Settings.ACTION_HOME_SETTINGS)) } - } - } - col.button("Open it") { startActivity(Intent(this, HomeActivity::class.java)) } - - col.header("Options") - col.check("Clock and date", null, Apps.showClock(this)) { Apps.setShowClock(this, it) } - col.check("Open the app when one match is left", "After at least two letters", Apps.autoOpen(this)) { - Apps.setAutoOpen(this, it) - } - - col.header("On the home screen") - val byPkg = Apps.all(this).associateBy { it.pkg } - val favs = Apps.favourites(this).mapNotNull { byPkg[it] } - if (favs.isEmpty()) col.note("Nothing yet.") - favs.forEach { app -> - col.row(app.label, "Tap to remove") { Apps.toggleFavourite(this, app.pkg); build() } - } - col.button("Add an app") { - val all = Apps.all(this).filter { it.pkg !in Apps.favourites(this) } - Ui.pick(this, "Keep on home", all.map { it.label }) { i -> - Apps.toggleFavourite(this, all[i].pkg); build() - } - } - - val hidden = Apps.hidden(this).mapNotNull { byPkg[it] } - if (hidden.isNotEmpty()) { - col.header("Hidden from search") - hidden.forEach { app -> - col.row(app.label, "Tap to show again") { Apps.toggleHidden(this, app.pkg); build() } - } - } - col.note("Press and hold empty space to come back here.") - } -} diff --git a/app/src/main/java/dev/equwal/assistkey/home/RecentsActivity.kt b/app/src/main/java/dev/equwal/assistkey/home/RecentsActivity.kt index 1be789a..9f8a50b 100644 --- a/app/src/main/java/dev/equwal/assistkey/home/RecentsActivity.kt +++ b/app/src/main/java/dev/equwal/assistkey/home/RecentsActivity.kt @@ -2,375 +2,31 @@ package dev.equwal.assistkey.home import android.app.Activity import android.content.Intent -import android.graphics.Color -import android.graphics.Typeface -import android.graphics.drawable.GradientDrawable -import android.provider.Settings -import android.util.TypedValue -import android.view.GestureDetector -import android.view.Gravity -import android.view.MotionEvent -import android.view.View -import android.view.ViewGroup.LayoutParams.MATCH_PARENT -import android.view.ViewGroup.LayoutParams.WRAP_CONTENT -import android.view.WindowInsets -import android.widget.HorizontalScrollView -import android.widget.ImageView -import android.widget.LinearLayout -import android.widget.TextView -import dev.equwal.assistkey.shell.Shell -import dev.equwal.assistkey.ui.ShellActivity -import dev.equwal.assistkey.ui.Ui -import dev.equwal.assistkey.ui.Ui.button -import dev.equwal.assistkey.ui.Ui.dp -import dev.equwal.assistkey.ui.Ui.note -import dev.equwal.assistkey.ui.Ui.title -import kotlin.math.abs +import android.os.Bundle +import android.widget.Toast +import dev.equwal.assistkey.ui.HomeHubActivity /** - * Recent apps, as a row of cards. + * The "Recent apps" action. The recent-apps screen is a program of its own, + * Ink Recents. This activity has no screen: it opens Ink Recents and ends. + * Where Ink Recents is not installed, it opens the screen that offers it. * - * It works like the Android switcher: swipe sideways through the cards, tap a - * card to go to that app, swipe a card up to close it. It is drawn for e-ink: - * black outlines on white, and the row jumps from card to card with no - * animation. - * - * Android does not tell apps what is running. With shell access the cards are - * the real tasks of the system, a card can be closed, and where the shell is - * root each card shows the picture the system keeps of the task. Without shell - * access the order comes from the usage log and a card shows the app icon. - * - * Bind it to a key from any action list: AssistKey > Recent apps. + * The class keeps its old name, because bindings made before hold the name. */ class RecentsActivity : Activity() { - private var row: LinearLayout? = null - private var scroller: HorizontalScrollView? = null - private var strip: LinearLayout? = null - private var heading: TextView? = null - private var closeOthers: TextView? = null - private var names: List<String> = emptyList() - private var shown: List<Apps.Recent> = emptyList() - private var cardStep = 0 - private var index = 0 - - /** Each time the screen opens, it starts at the newest app and shows the hint again. */ - override fun onStart() { - super.onStart() - index = 0 - hinted = false - } - - override fun onResume() { - super.onResume() - load() - } - - private fun load() { - Apps.recentFromShell(this) { fromShell -> - if (isFinishing) return@recentFromShell - when { - fromShell != null -> show(fromShell, closable = true) - Apps.hasUsageAccess(this) -> - show(Apps.recentFromUsage(this).map { Apps.Recent(it, null) }, closable = false) - else -> askForAccess() - } - } - } - - // ---- the cards ------------------------------------------------------------------------- - - private fun show(recents: List<Apps.Recent>, closable: Boolean) { - val screenW = resources.displayMetrics.widthPixels - val screenH = resources.displayMetrics.heightPixels - val cardW = (screenW * 0.70f).toInt() - val cardH = (screenH * 0.56f).toInt() - val gap = dp(16) - cardStep = cardW + gap - names = recents.map { it.app.label } - shown = recents - index = index.coerceIn(0, maxOf(0, recents.size - 1)) - - val root = LinearLayout(this).apply { - orientation = LinearLayout.VERTICAL - setBackgroundColor(Color.WHITE) - setOnApplyWindowInsetsListener { v, insets -> - val bars = insets.getInsets(WindowInsets.Type.systemBars()) - v.setPadding(bars.left, bars.top, bars.right, bars.bottom) - insets - } - } - heading = TextView(this).apply { - text = if (recents.isEmpty()) "Nothing recent" else "Recent apps" - setTextColor(Color.BLACK) - setTextSize(TypedValue.COMPLEX_UNIT_SP, 22f) - typeface = Typeface.DEFAULT_BOLD - setSingleLine() - setPadding(dp(20), dp(16), dp(20), dp(12)) - } - root.addView(heading) - - val cards = LinearLayout(this).apply { - orientation = LinearLayout.HORIZONTAL - // Side padding puts the first and the last card in the middle too. - val side = (screenW - cardW) / 2 - setPadding(side, 0, side, 0) - } - recents.forEach { r -> - cards.addView( - card(r, closable), - LinearLayout.LayoutParams(cardW, cardH).apply { marginEnd = gap } - ) - } - row = cards - - val scroll = object : HorizontalScrollView(this) { - private val slop = android.view.ViewConfiguration.get(context).scaledTouchSlop - private var downX = 0f - private var downY = 0f - - /** A sideways drag belongs to the row. A tap or an upward swipe belongs to the card. */ - override fun onInterceptTouchEvent(ev: MotionEvent): Boolean { - when (ev.actionMasked) { - MotionEvent.ACTION_DOWN -> { downX = ev.x; downY = ev.y } - MotionEvent.ACTION_MOVE -> { - val dx = abs(ev.x - downX) - if (dx > slop && dx > abs(ev.y - downY)) return true - } - } - return false - } - - /** - * One swipe, one card, in one step. The row does not follow the - * finger: e-ink shows every frame of a moving row as a smear. - */ - override fun onTouchEvent(ev: MotionEvent): Boolean { - if (ev.actionMasked == MotionEvent.ACTION_UP) { - val dx = ev.x - downX - if (abs(dx) > slop) show(index + if (dx < 0) 1 else -1) - } - return true - } - }.apply { - isHorizontalScrollBarEnabled = false - overScrollMode = View.OVER_SCROLL_NEVER - addView(cards, LinearLayout.LayoutParams(WRAP_CONTENT, WRAP_CONTENT)) - } - scroller = scroll - root.addView(scroll, LinearLayout.LayoutParams(MATCH_PARENT, WRAP_CONTENT)) - - // Every recent app at once, as icons. The one in the middle is marked, - // and a tap on an icon brings its card to the middle. - val icons = LinearLayout(this).apply { - orientation = LinearLayout.HORIZONTAL - gravity = Gravity.CENTER_HORIZONTAL - setPadding(dp(12), dp(14), dp(12), dp(4)) - } - recents.forEachIndexed { i, r -> - icons.addView( - ImageView(this).apply { - setImageDrawable(runCatching { packageManager.getApplicationIcon(r.app.pkg) }.getOrNull()) - contentDescription = r.app.label - val p = dp(6) - setPadding(p, p, p, p) - setOnClickListener { show(i) } - }, - LinearLayout.LayoutParams(dp(52), dp(52)).apply { marginEnd = dp(4) } - ) - } - strip = icons - root.addView( - HorizontalScrollView(this).apply { - isHorizontalScrollBarEnabled = false - addView(icons, LinearLayout.LayoutParams(WRAP_CONTENT, WRAP_CONTENT)) - }, - LinearLayout.LayoutParams(MATCH_PARENT, WRAP_CONTENT) - ) - - closeOthers = null - if (recents.isNotEmpty()) { - // Two tall buttons, one above the other with space between, so that - // a finger does not hit the wrong one. - fun bigButton(black: Boolean, onClick: () -> Unit) = TextView(this).apply { - gravity = Gravity.CENTER - setTextSize(TypedValue.COMPLEX_UNIT_SP, 18f) - typeface = Typeface.DEFAULT_BOLD - setSingleLine() - setTextColor(if (black) Color.WHITE else Color.BLACK) - background = GradientDrawable().apply { - setColor(if (black) Color.BLACK else Color.WHITE) - setStroke(dp(2), Color.BLACK) - cornerRadius = dp(8).toFloat() - } - setOnClickListener { onClick() } - } - fun place(v: View, top: Int) = root.addView( - v, - LinearLayout.LayoutParams(MATCH_PARENT, dp(60)).apply { setMargins(dp(20), top, dp(20), 0) } - ) - if (recents.size > 1) { - // The name is filled in by show(i): it is the card in the middle. - closeOthers = bigButton(black = true) { close(recents.filterIndexed { i, _ -> i != index }) } - place(closeOthers!!, dp(8)) - } - val all = bigButton(black = false) { close(recents) }.apply { text = "Close all" } - place(all, dp(20)) + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + val open = Intent(ACTION_OPEN).setPackage(INK_RECENTS).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + if (runCatching { startActivity(open) }.isFailure) { + Toast.makeText(this, "Install Ink Recents", Toast.LENGTH_LONG).show() + startActivity(Intent(this, HomeHubActivity::class.java).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)) } - setContentView(root) - scroll.post { show(index); hint() } + finish() } - /** - * Closes apps. With shell access the task leaves the list of the system and - * its background processes end. Without it, Android ends the background - * processes and the app leaves the list of this screen. - */ - private fun close(apps: List<Apps.Recent>) { - index = 0 - val mine = apps.filter { it.app.pkg != packageName } - val (withTask, without) = mine.partition { it.taskId != null && Shell.ready } - without.forEach { Apps.closeWithoutShell(this, it.app.pkg) } - if (withTask.isEmpty()) return load() - Shell.runAll(withTask.flatMap { listOf("am stack remove " + it.taskId, "am kill " + it.app.pkg) }) { load() } - } - - private var hinted = false - - /** - * Shows once what a swipe does: the card in the middle jumps up and comes - * back, then the row jumps sideways and comes back. Four still frames, no - * glide, so that e-ink draws them clean. - */ - private fun hint() { - if (hinted) return - hinted = true - val s = scroller ?: return - val card = row?.getChildAt(index) ?: return - val many = (row?.childCount ?: 0) > 1 - s.postDelayed({ card.translationY = -dp(28).toFloat() }, 500) - s.postDelayed({ card.translationY = 0f }, 900) - if (many) { - val side = if (index == 0) dp(40) else -dp(40) - s.postDelayed({ s.scrollTo(index * cardStep + side, 0) }, 1300) - s.postDelayed({ s.scrollTo(index * cardStep, 0) }, 1700) - } - } - - /** Puts card [i] in the middle, in one step, and says which one it is. */ - private fun show(i: Int) { - val s = scroller ?: return - val count = row?.childCount ?: 0 - if (cardStep <= 0 || count == 0) return - index = i.coerceIn(0, count - 1) - s.scrollTo(index * cardStep, 0) - heading?.text = names.getOrElse(index) { "" } + " " + (index + 1) + " of " + count - closeOthers?.text = "Close all but " + names.getOrElse(index) { "this one" } - strip?.let { icons -> - for (n in 0 until icons.childCount) { - icons.getChildAt(n).background = if (n != index) null else GradientDrawable().apply { - setStroke(dp(2), Color.BLACK) - cornerRadius = dp(10).toFloat() - } - } - } - } - - private fun card(r: Apps.Recent, closable: Boolean): View { - val box = LinearLayout(this).apply { - orientation = LinearLayout.VERTICAL - background = GradientDrawable().apply { - setColor(Color.WHITE) - setStroke(dp(2), Color.BLACK) - cornerRadius = dp(14).toFloat() - } - val p = dp(3) - setPadding(p, p, p, p) - clipToOutline = true - } - - val icon = runCatching { packageManager.getApplicationIcon(r.app.pkg) }.getOrNull() - box.addView(LinearLayout(this).apply { - orientation = LinearLayout.HORIZONTAL - gravity = Gravity.CENTER_VERTICAL - setPadding(dp(12), dp(10), dp(12), dp(10)) - addView(ImageView(context).apply { setImageDrawable(icon) }, LinearLayout.LayoutParams(dp(32), dp(32))) - addView(TextView(context).apply { - text = r.app.label - setTextColor(Color.BLACK) - setTextSize(TypedValue.COMPLEX_UNIT_SP, 17f) - setSingleLine() - setPadding(dp(10), 0, 0, 0) - }) - }) - box.addView(View(this).apply { setBackgroundColor(Color.BLACK) }, LinearLayout.LayoutParams(MATCH_PARENT, dp(1))) - - // The body: the app icon now, the task picture when and if it arrives. - val body = ImageView(this).apply { - setImageDrawable(icon) - scaleType = ImageView.ScaleType.CENTER_INSIDE - val p = dp(64) - setPadding(p, p, p, p) - } - box.addView(body, LinearLayout.LayoutParams(MATCH_PARENT, 0, 1f)) - if (r.app.pkg != packageName) { - box.addView(View(this).apply { setBackgroundColor(Color.BLACK) }, LinearLayout.LayoutParams(MATCH_PARENT, dp(1))) - box.addView(TextView(this).apply { - text = "\u2191 close this \u2193 close the others" - setTextColor(Color.BLACK) - setTextSize(TypedValue.COMPLEX_UNIT_SP, 14f) - gravity = Gravity.CENTER_HORIZONTAL - setPadding(0, dp(8), 0, dp(8)) - }) - } - r.taskId?.let { id -> - Apps.snapshot(id) { picture -> - if (picture != null && !isFinishing) { - body.setPadding(0, 0, 0, 0) - body.scaleType = ImageView.ScaleType.FIT_START - body.setImageBitmap(picture) - } - } - } - - val gestures = GestureDetector(this, object : GestureDetector.SimpleOnGestureListener() { - override fun onDown(e: MotionEvent): Boolean = true - - override fun onSingleTapUp(e: MotionEvent): Boolean { - Apps.launch(this@RecentsActivity, r.app) - finish() - return true - } - - override fun onFling(e1: MotionEvent?, e2: MotionEvent, vx: Float, vy: Float): Boolean { - val up = e1 != null && e1.y - e2.y > box.height / 5 && abs(vy) > abs(vx) - val down = e1 != null && e2.y - e1.y > box.height / 5 && abs(vy) > abs(vx) - when { - up -> close(listOf(r)) - down -> close(shown.filter { it !== r }) - else -> return false - } - return true - } - }) - box.setOnTouchListener { v, e -> - if (e.actionMasked == MotionEvent.ACTION_UP) v.performClick() - gestures.onTouchEvent(e) - } - return box - } - - // ---- no access yet --------------------------------------------------------------------- - - private fun askForAccess() { - val col = Ui.page(this) - col.title("Recent apps") - col.note("Usage access puts your apps in order of last use.") - col.button("Give usage access") { - runCatching { startActivity(Intent(Settings.ACTION_USAGE_ACCESS_SETTINGS)) } - } - if (Shell.SUPPORTED) { - col.button("Shell access") { startActivity(Intent(this, ShellActivity::class.java)) } - } + companion object { + const val INK_RECENTS = "dev.equwal.inkrecents" + const val ACTION_OPEN = "dev.equwal.inkrecents.OPEN" } } diff --git a/app/src/main/java/dev/equwal/assistkey/setup/GuidedSetupActivity.kt b/app/src/main/java/dev/equwal/assistkey/setup/GuidedSetupActivity.kt index e1c9216..85bb9c5 100644 --- a/app/src/main/java/dev/equwal/assistkey/setup/GuidedSetupActivity.kt +++ b/app/src/main/java/dev/equwal/assistkey/setup/GuidedSetupActivity.kt @@ -182,9 +182,7 @@ class GuidedSetupActivity : Activity() { } when { HwKey.SCREEN in set -> col.note("A small button floats over every app. Drag to move it.") - HwKey.POWER in set && set.size > 1 -> - col.note("Hold Power, then press the other button.") - HwKey.POWER in set && !env.powerIsDirect && !env.shellSupported -> + set == setOf(HwKey.POWER) && !env.powerIsDirect && !env.shellSupported -> col.note("A tap cannot reach any app. Hold and double tap work.") } if (HwKey.SCREEN !in set) { diff --git a/app/src/main/java/dev/equwal/assistkey/store/SettingsFile.kt b/app/src/main/java/dev/equwal/assistkey/store/SettingsFile.kt index baf2496..8e297a4 100644 --- a/app/src/main/java/dev/equwal/assistkey/store/SettingsFile.kt +++ b/app/src/main/java/dev/equwal/assistkey/store/SettingsFile.kt @@ -29,7 +29,6 @@ object SettingsFile { "assistkey" to setOf("bindings", "multitap_ms", "hold_ms", "chord_ms"), "assistkey_channels" to null, "assistkey_nav" to setOf("buttons", "gestures", "keys"), - "assistkey_home" to setOf("favourites", "hidden", "clock", "auto_open"), "assistkey_voice" to setOf("engine", "language"), "assistkey_device" to setOf("ai_key_returns"), "assistkey_power" to setOf("wanted") diff --git a/app/src/main/java/dev/equwal/assistkey/ui/ActionPickerActivity.kt b/app/src/main/java/dev/equwal/assistkey/ui/ActionPickerActivity.kt index a38ffda..552dff7 100644 --- a/app/src/main/java/dev/equwal/assistkey/ui/ActionPickerActivity.kt +++ b/app/src/main/java/dev/equwal/assistkey/ui/ActionPickerActivity.kt @@ -114,21 +114,9 @@ class ActionPickerActivity : Activity() { private fun own(col: LinearLayout) { col.header("Rebind") - listOf( - "Recent apps (cards)" to "dev.equwal.assistkey.home.RecentsActivity", - "App search" to "dev.equwal.assistkey.home.HomeActivity" - ).filter { (_, cls) -> - // The home screen is switched off until asked for; a disabled - // activity cannot be launched, so it is not offered. - packageManager.getComponentEnabledSetting(android.content.ComponentName(packageName, cls)) != - android.content.pm.PackageManager.COMPONENT_ENABLED_STATE_DISABLED && - (cls.endsWith("RecentsActivity") || - packageManager.getComponentEnabledSetting(android.content.ComponentName(packageName, cls)) == - android.content.pm.PackageManager.COMPONENT_ENABLED_STATE_ENABLED) - }.forEach { (label, cls) -> - col.row(label, null) { - choose(ActionSpec(ActionKind.LAUNCH_COMPONENT, packageName + "/" + cls, label)) - } + val label = "Recent apps (cards)" + col.row(label, null) { + choose(ActionSpec(ActionKind.LAUNCH_COMPONENT, packageName + "/dev.equwal.assistkey.home.RecentsActivity", label)) } } diff --git a/app/src/main/java/dev/equwal/assistkey/ui/BundledRows.kt b/app/src/main/java/dev/equwal/assistkey/ui/BundledRows.kt new file mode 100644 index 0000000..7bc8329 --- /dev/null +++ b/app/src/main/java/dev/equwal/assistkey/ui/BundledRows.kt @@ -0,0 +1,28 @@ +package dev.equwal.assistkey.ui + +import android.app.Activity +import android.widget.LinearLayout +import dev.equwal.assistkey.bundle.Bundled +import dev.equwal.assistkey.ui.Ui.header +import dev.equwal.assistkey.ui.Ui.row + +/** The apps of other makers that belong on one screen: installed, or one tap to get. */ +object BundledRows { + + fun add(a: Activity, col: LinearLayout, group: String) { + val items = Bundled.items.filter { it.group == group } + if (items.isEmpty()) return + col.header("Apps of other makers") + items.forEach { item -> + val installed = runCatching { a.packageManager.getPackageInfo(item.pkg, 0) }.isSuccess + col.row( + item.title, + item.hint + " · " + item.version + " · " + item.licence, + state = if (installed) "Installed" else if (Bundled.SUPPORTED) "Install" else "Get it" + ) { + if (!installed) Bundled.install(a, item) + else a.packageManager.getLaunchIntentForPackage(item.pkg)?.let(a::startActivity) + } + } + } +} diff --git a/app/src/main/java/dev/equwal/assistkey/ui/HacksActivity.kt b/app/src/main/java/dev/equwal/assistkey/ui/HacksActivity.kt index 56c7e18..f82e7a0 100644 --- a/app/src/main/java/dev/equwal/assistkey/ui/HacksActivity.kt +++ b/app/src/main/java/dev/equwal/assistkey/ui/HacksActivity.kt @@ -35,12 +35,12 @@ class HacksActivity : Activity() { private fun build() { val col = Ui.page(this, "Hardware hacks") - fun door(title: String, hint: String, ch: Channel) = col.row( - title, hint, state = if (Channels.isSatisfied(this, ch)) "On" else "Off" + fun door(title: String, hint: String, vararg ways: Channel) = col.row( + title, hint, state = if (ways.any { Channels.isSatisfied(this, it) }) "On" else "Off" ) { startActivity(Intent(this, PowerActivity::class.java)) } door("Hold Power", "Through the assistant role", Channel.ASSISTANT) - door("Double tap Power", "Through the camera intent. No camera button.", Channel.CAMERA) - door("Wallet button", "Through the wallet role", Channel.WALLET) + // One gesture, two ways in. A device sends the double tap to the camera or to the wallet. + door("Double tap Power", "Through the camera or the wallet intent", Channel.CAMERA, Channel.WALLET) if (Shell.SUPPORTED) { col.row( "Full Power button", diff --git a/app/src/main/java/dev/equwal/assistkey/ui/HomeHubActivity.kt b/app/src/main/java/dev/equwal/assistkey/ui/HomeHubActivity.kt index f5ba60e..07c8a74 100644 --- a/app/src/main/java/dev/equwal/assistkey/ui/HomeHubActivity.kt +++ b/app/src/main/java/dev/equwal/assistkey/ui/HomeHubActivity.kt @@ -1,15 +1,11 @@ package dev.equwal.assistkey.ui import android.app.Activity -import android.content.ComponentName import android.content.Intent -import android.content.pm.PackageManager -import dev.equwal.assistkey.home.HomeActivity -import dev.equwal.assistkey.home.HomeSettingsActivity -import dev.equwal.assistkey.home.RecentsActivity +import dev.equwal.assistkey.bundle.Bundled import dev.equwal.assistkey.ui.Ui.row -/** The two screens AssistKey draws for itself: the home screen, and recents. */ +/** The home screen and the recent-apps screen. Both are apps of their own, which this screen offers. */ class HomeHubActivity : Activity() { override fun onResume() { @@ -20,24 +16,6 @@ class HomeHubActivity : Activity() { private fun build() { val col = Ui.page(this, "Home and recents") - val offered = packageManager.getComponentEnabledSetting( - ComponentName(this, HomeActivity::class.java) - ) == PackageManager.COMPONENT_ENABLED_STATE_ENABLED - val isDefault = packageManager.resolveActivity( - Intent(Intent.ACTION_MAIN).addCategory(Intent.CATEGORY_HOME), - PackageManager.MATCH_DEFAULT_ONLY - )?.activityInfo?.packageName == packageName - - col.row( - "Home screen", - "A clock, your apps, and a search line", - state = if (isDefault) "In use" else if (offered) "Ready" else "Off" - ) { startActivity(Intent(this, HomeSettingsActivity::class.java)) } - - col.row( - "Recent apps", - "Cards you can bind to a button" - ) { startActivity(Intent(this, RecentsActivity::class.java)) } - + BundledRows.add(this, col, Bundled.HOME) } } diff --git a/app/src/main/java/dev/equwal/assistkey/ui/MainActivity.kt b/app/src/main/java/dev/equwal/assistkey/ui/MainActivity.kt index 80e6162..1b69096 100644 --- a/app/src/main/java/dev/equwal/assistkey/ui/MainActivity.kt +++ b/app/src/main/java/dev/equwal/assistkey/ui/MainActivity.kt @@ -14,7 +14,6 @@ import android.widget.TextView import dev.equwal.assistkey.channel.Channel import dev.equwal.assistkey.channel.Channels import dev.equwal.assistkey.device.Detect -import dev.equwal.assistkey.home.HomeActivity import dev.equwal.assistkey.license.License import dev.equwal.assistkey.license.PlayBilling import dev.equwal.assistkey.model.GestureType @@ -174,16 +173,11 @@ class MainActivity : Activity() { ) } - private fun homeSummary(): String { - val cn = android.content.ComponentName(this, HomeActivity::class.java) - val offered = packageManager.getComponentEnabledSetting(cn) == - android.content.pm.PackageManager.COMPONENT_ENABLED_STATE_ENABLED - val isDefault = packageManager.resolveActivity( - Intent(Intent.ACTION_MAIN).addCategory(Intent.CATEGORY_HOME), - android.content.pm.PackageManager.MATCH_DEFAULT_ONLY - )?.activityInfo?.packageName == packageName - return Summary.homeAndRecents(offered, isDefault) - } + private fun homeSummary(): String = Summary.homeAndRecents( + listOf("app.clauncher", "dev.equwal.inkrecents").count { pkg -> + runCatching { packageManager.getPackageInfo(pkg, 0) }.isSuccess + } + ) private fun setupSummary(): String { val granted = PermissionsActivity.granted(this) diff --git a/app/src/main/java/dev/equwal/assistkey/ui/PermissionsActivity.kt b/app/src/main/java/dev/equwal/assistkey/ui/PermissionsActivity.kt index 3b4648b..5a2e8d8 100644 --- a/app/src/main/java/dev/equwal/assistkey/ui/PermissionsActivity.kt +++ b/app/src/main/java/dev/equwal/assistkey/ui/PermissionsActivity.kt @@ -8,7 +8,6 @@ import android.provider.Settings import android.widget.LinearLayout import dev.equwal.assistkey.channel.Channel import dev.equwal.assistkey.channel.Channels -import dev.equwal.assistkey.home.Apps import dev.equwal.assistkey.native.PowerNative import dev.equwal.assistkey.shell.Shell import dev.equwal.assistkey.ui.Ui.button @@ -71,11 +70,6 @@ class PermissionsActivity : Activity() { "For Voice typing", state[2] ) { requestPermissions(arrayOf(Manifest.permission.RECORD_AUDIO), 1) } - list += Item( - "App usage data", - "Puts the recent apps cards in order", - state[3] - ) { start(Intent(Settings.ACTION_USAGE_ACCESS_SETTINGS)) } return list } @@ -160,8 +154,7 @@ class PermissionsActivity : Activity() { fun granted(c: Context): List<Boolean> = listOf( Channels.isSatisfied(c, Channel.ACCESSIBILITY), Channels.isSatisfied(c, Channel.ASSISTANT), - Dictation.hasMicrophone(c), - Apps.hasUsageAccess(c) || Shell.ready + Dictation.hasMicrophone(c) ) /** Opens this screen the first time the app runs, and never again by itself. */ diff --git a/app/src/main/java/dev/equwal/assistkey/ui/Summary.kt b/app/src/main/java/dev/equwal/assistkey/ui/Summary.kt index a4c6c95..216e3cd 100644 --- a/app/src/main/java/dev/equwal/assistkey/ui/Summary.kt +++ b/app/src/main/java/dev/equwal/assistkey/ui/Summary.kt @@ -50,11 +50,11 @@ object Summary { else -> "Ready" } - /** The home screen and the recent-apps cards. */ - fun homeAndRecents(homeOffered: Boolean, isDefault: Boolean): String = when { - isDefault -> "Home screen in use" - homeOffered -> "Home screen ready" - else -> "Recent apps only" + /** CLauncher and Ink Recents: how many of the two are installed. */ + fun homeAndRecents(installed: Int): String = when (installed) { + 0 -> "CLauncher, Ink Recents" + 2 -> "Both installed" + else -> "1 of 2 installed" } /** diff --git a/app/src/main/java/dev/equwal/assistkey/ui/VoiceActivity.kt b/app/src/main/java/dev/equwal/assistkey/ui/VoiceActivity.kt index d522326..04015ea 100644 --- a/app/src/main/java/dev/equwal/assistkey/ui/VoiceActivity.kt +++ b/app/src/main/java/dev/equwal/assistkey/ui/VoiceActivity.kt @@ -8,6 +8,7 @@ import android.provider.Settings import dev.equwal.assistkey.channel.Channel import dev.equwal.assistkey.channel.Channels import dev.equwal.assistkey.voice.Dictation +import dev.equwal.assistkey.bundle.Bundled import dev.equwal.assistkey.ui.Ui.button import dev.equwal.assistkey.ui.Ui.header import dev.equwal.assistkey.ui.Ui.note @@ -58,18 +59,9 @@ class VoiceActivity : Activity() { } } if (engines.none(Dictation::isOnDevice)) { - col.note("No app here recognises speech offline. Whisper, from F-Droid, is free.") - col.button("Get Whisper") { - val pages = listOf( - "market://details?id=" + Dictation.WHISPER_PACKAGE, - "https://f-droid.org/packages/" + Dictation.WHISPER_PACKAGE + "/" - ) - pages.firstOrNull { url -> - runCatching { startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(url))) }.isSuccess - } - } - col.note("In Whisper, download a model and allow the microphone.") + col.note("No app here recognises speech offline. Whisper does, and it is free.") } + BundledRows.add(this, col, Bundled.VOICE) col.header("Language") val lang = Dictation.language(this) diff --git a/app/src/play/java/dev/equwal/assistkey/bundle/Bundled.kt b/app/src/play/java/dev/equwal/assistkey/bundle/Bundled.kt new file mode 100644 index 0000000..c1d2477 --- /dev/null +++ b/app/src/play/java/dev/equwal/assistkey/bundle/Bundled.kt @@ -0,0 +1,49 @@ +package dev.equwal.assistkey.bundle + +import android.app.Activity +import android.content.Intent +import android.net.Uri + +/** + * The Google Play build carries no apps of other makers and cannot install + * any: Google Play allows that only for an app whose main purpose it is. It + * names the same apps and opens the page of the maker. + */ +object Bundled { + + const val SUPPORTED = false + + class Item( + /** The screen that lists the app: HOME or VOICE. */ + val group: String, + val title: String, + val hint: String, + val pkg: String, + val version: String, + val licence: String, + val source: String + ) + + const val HOME = "home" + const val VOICE = "voice" + + val items = listOf( + Item( + HOME, "CLauncher", "Minimal home screen", "app.clauncher", "v5.3.0", "GPL-3.0", + "https://github.com/mlm-games/CLauncher" + ), + Item( + HOME, "Ink Recents", "Recent apps, drawn for e-ink", "dev.equwal.inkrecents", "0.1.1", "GPL-3.0", + "https://github.com/equwal/ink-recents" + ), + Item( + VOICE, "Whisper", "Speech to text on the device", "org.woheller69.whisper", "3.7", "MIT", + "https://f-droid.org/packages/org.woheller69.whisper/" + ) + ) + + /** Opens the page where the maker offers the app. */ + fun install(a: Activity, item: Item) { + runCatching { a.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(item.source))) } + } +} diff --git a/app/src/test/java/dev/equwal/assistkey/home/RecentsOpenTest.kt b/app/src/test/java/dev/equwal/assistkey/home/RecentsOpenTest.kt deleted file mode 100644 index 3ede8e0..0000000 --- a/app/src/test/java/dev/equwal/assistkey/home/RecentsOpenTest.kt +++ /dev/null @@ -1,30 +0,0 @@ -package dev.equwal.assistkey.home - -import org.junit.Assert.assertEquals -import org.junit.Assert.assertTrue -import org.junit.Test -import java.util.Random - -class RecentsOpenTest { - - @Test fun `a closed app stays off the list until it is opened again`() { - val used = mapOf("reader" to 100L, "notes" to 200L, "mail" to 300L) - assertEquals(listOf("mail", "notes", "reader"), Apps.open(used, emptyMap())) - assertEquals(listOf("mail", "reader"), Apps.open(used, mapOf("notes" to 250L))) - // Opened again after the close: back on the list. - assertEquals(listOf("mail", "notes", "reader"), Apps.open(used, mapOf("notes" to 150L))) - } - - @Test fun `the list is newest first, has no app twice, and has no app closed after its last use`() { - val r = Random(11) - repeat(500) { - val used = (0 until r.nextInt(8)).associate { "app" + r.nextInt(10) to r.nextInt(1000).toLong() } - val closed = (0 until r.nextInt(6)).associate { "app" + r.nextInt(10) to r.nextInt(1000).toLong() } - val shown = Apps.open(used, closed) - assertEquals(shown.distinct(), shown) - assertEquals(shown.sortedByDescending { used.getValue(it) }.map { used.getValue(it) }, shown.map { used.getValue(it) }) - assertTrue(shown.all { used.getValue(it) > (closed[it] ?: Long.MIN_VALUE) }) - assertTrue(used.keys.filter { it !in shown }.all { used.getValue(it) <= closed.getValue(it) }) - } - } -} diff --git a/app/src/test/java/dev/equwal/assistkey/ui/SummaryTest.kt b/app/src/test/java/dev/equwal/assistkey/ui/SummaryTest.kt index ad18b13..3dcebdd 100644 --- a/app/src/test/java/dev/equwal/assistkey/ui/SummaryTest.kt +++ b/app/src/test/java/dev/equwal/assistkey/ui/SummaryTest.kt @@ -55,9 +55,9 @@ class SummaryTest { @Test fun `home and recents says what is in use`() { - assertEquals("Home screen in use", Summary.homeAndRecents(homeOffered = true, isDefault = true)) - assertEquals("Home screen ready", Summary.homeAndRecents(homeOffered = true, isDefault = false)) - assertEquals("Recent apps only", Summary.homeAndRecents(homeOffered = false, isDefault = false)) + assertEquals("CLauncher, Ink Recents", Summary.homeAndRecents(0)) + assertEquals("1 of 2 installed", Summary.homeAndRecents(1)) + assertEquals("Both installed", Summary.homeAndRecents(2)) } @Test diff --git a/gradle.properties b/gradle.properties index dd01f89..8f7de54 100644 --- a/gradle.properties +++ b/gradle.properties @@ -6,8 +6,8 @@ android.nonTransitiveRClass=true kotlin.code.style=official # Release identity. versionCode must rise with every upload to Play. -assistkey.versionName=0.0.11-alpha -assistkey.versionCode=11 +assistkey.versionName=0.0.12-alpha +assistkey.versionCode=12 # The free beta ends on this date (UTC) at the latest. It is the backstop for # installs that cannot reach Google Play; the real switch is the diff --git a/play/DECLARATIONS.md b/play/DECLARATIONS.md index fa75a87..0eaaf93 100644 --- a/play/DECLARATIONS.md +++ b/play/DECLARATIONS.md @@ -44,7 +44,7 @@ so it is not a financial feature.) Why "No" is accurate: the app holds no `INTERNET` permission (`aapt2 dump badging` on the release shows `WRITE_SECURE_SETTINGS`, -`PACKAGE_USAGE_STATS`, `RECORD_AUDIO`, `QUICK_ACCESS_WALLET` and +`RECORD_AUDIO`, `QUICK_ACCESS_WALLET` and `com.android.vending.BILLING`, none of which is network access). These answers are for the `play` build, which is the only one uploaded. The device report is composed on the device and leaves it only if the user sends it, through their @@ -93,7 +93,6 @@ pointed at the reader - the reader cannot capture its own screen. | `BIND_ACCESSIBILITY_SERVICE` | Above. | | `WRITE_SECURE_SETTINGS` | Cannot be granted to a Play install; it does nothing unless the owner grants it over adb. It then lets the app switch the firmware's power-button behaviour (short press, hold duration). Declared so that the grant is possible at all. | | `RECORD_AUDIO` | Voice typing only. Requested at run time from the Voice typing screen. The speech recognition app the user chose records the audio; Android requires the calling app to hold the permission too. AssistKey receives text only, and stores and sends nothing. | -| `PACKAGE_USAGE_STATS` | Special access the user grants from a settings screen. Used only by the recent-apps list to order apps by last use, on the device; nothing is stored or sent. Not needed when shell access is on. | | `QUICK_ACCESS_WALLET` | Required of any app offered as the wallet app. AssistKey serves an empty card list; it exists so a press of the wallet shortcut reaches the user's chosen action. | | `com.android.vending.BILLING` | Play Billing. | diff --git a/play/LISTING.md b/play/LISTING.md index f97b1f0..40f9bd7 100644 --- a/play/LISTING.md +++ b/play/LISTING.md @@ -65,8 +65,7 @@ VOICE TYPING Bind a key to Voice typing: press, speak, and the words appear where the cursor is - in any app, with the keyboard you already have. Rebind has no speech engine and no internet access; a speech recognition app on your device does the listening. MORE FOR E-INK -• A plain text home screen: a clock, a few apps, type to find the rest -• Recent apps as swipe cards, drawn for e-ink with no animation +• Opens Ink Recents, our free recent-apps app for e-ink, from any button BUILT FOR E-INK @@ -88,7 +87,7 @@ GOOD TO KNOW • If a volume key does not respond, the device's own key settings may be holding on to it. The built-in key tester shows exactly which keys Rebind can see, and the Firmware key hooks screen shows the fix. COMPATIBILITY -Works on Android 12 and later. Built and tested on the Viwoods AiPaper Reader; key remapping, page turning, voice typing and the home screen work on Boox, Bigme, Meebook and ordinary phones and tablets too. Search terms people use for this: button mapper, key remapper, remap buttons, volume key page turn, double tap, long press, key combination. +Works on Android 12 and later. Built and tested on the Viwoods AiPaper Reader; key remapping, page turning, and voice typing work on Boox, Bigme, Meebook and ordinary phones and tablets too. Search terms people use for this: button mapper, key remapper, remap buttons, volume key page turn, double tap, long press, key combination. Rebind is not affiliated with, or endorsed by, Viwoods, Onyx Boox, Bigme or Meebook. diff --git a/play/PRIVACY.md b/play/PRIVACY.md index f61a26c..ede5cec 100644 --- a/play/PRIVACY.md +++ b/play/PRIVACY.md @@ -34,12 +34,9 @@ field that has the cursor. To do that it reads the current text of that one field at that moment. It keeps and sends none of it, and never writes into password fields. -**Recent apps.** If you use the recent-apps list without shell access, it -reads the usage log Android keeps, with the usage access you grant, to put apps -in order of last use. This happens on the device each time the list opens; -nothing from it is stored or sent. +**Recent apps.** Rebind has no recent-apps list of its own. The Recent apps action opens Ink Recents, a separate app with a privacy policy of its own. -**Closing apps.** When you close an app in the recent-apps list, Rebind asks Android to end the background processes of that app, and remembers on your device the time you closed it, so that the app stays off the list until you open it again. +**Apps of other makers.** The build for direct install carries the release files of three other apps (CLauncher, Ink Recents, Whisper) and can hand them to the installer of Android, which asks you first. Rebind downloads nothing. Each of those apps has its own policy. The Google Play build carries none of them. **The on-screen button.** If you give the on-screen button an action, the accessibility service draws a small round button over other apps. It reads nothing from the screen. It remembers only where you put it.