├── .envrc
├── app
├── .gitignore
├── src
│ ├── main
│ │ ├── ic_launcher-playstore.png
│ │ ├── res
│ │ │ ├── drawable
│ │ │ │ ├── floating_buttons_background.xml
│ │ │ │ ├── button_textarea_pressed.xml
│ │ │ │ ├── button_textarea_enabled.xml
│ │ │ │ ├── button_textarea_focused.xml
│ │ │ │ ├── layers_24dp.xml
│ │ │ │ ├── button_textarea.xml
│ │ │ │ ├── ic_launcher_foreground.xml
│ │ │ │ ├── content_paste_go_24dp.xml
│ │ │ │ ├── ic_launcher_background.xml
│ │ │ │ └── directions_boat_24dp.xml
│ │ │ ├── layout
│ │ │ │ ├── settings_activity.xml
│ │ │ │ └── floating_buttons.xml
│ │ │ ├── mipmap-anydpi
│ │ │ │ └── ic_launcher.xml
│ │ │ ├── values
│ │ │ │ ├── styles.xml
│ │ │ │ ├── arrays.xml
│ │ │ │ ├── colors.xml
│ │ │ │ ├── themes.xml
│ │ │ │ └── strings.xml
│ │ │ ├── xml
│ │ │ │ ├── backup_rules.xml
│ │ │ │ ├── accessibility_service_config.xml
│ │ │ │ ├── data_extraction_rules.xml
│ │ │ │ └── root_preferences.xml
│ │ │ └── values-night
│ │ │ │ └── themes.xml
│ │ ├── java
│ │ │ └── sh
│ │ │ │ └── eliza
│ │ │ │ └── textbender
│ │ │ │ ├── DummyActivity.kt
│ │ │ │ ├── Toaster.kt
│ │ │ │ ├── BendClipboardTileService.kt
│ │ │ │ ├── FloatingButtonsTileService.kt
│ │ │ │ ├── ActivateOverlayTileService.kt
│ │ │ │ ├── BendClipboardActivity.kt
│ │ │ │ ├── OcclusionBuffer.kt
│ │ │ │ ├── ProcessTextActivity.kt
│ │ │ │ ├── ImmutableRect.kt
│ │ │ │ ├── TextbenderTileService.kt
│ │ │ │ ├── Extensions.kt
│ │ │ │ ├── Textbender.kt
│ │ │ │ ├── FloatingButtons.kt
│ │ │ │ ├── OpenYomichanStateMachine.kt
│ │ │ │ ├── Snapshot.kt
│ │ │ │ ├── TextbenderPreferences.kt
│ │ │ │ ├── TextbenderService.kt
│ │ │ │ └── SettingsActivity.kt
│ │ └── AndroidManifest.xml
│ ├── test
│ │ └── java
│ │ │ └── sh
│ │ │ └── eliza
│ │ │ └── textbender
│ │ │ └── ExampleUnitTest.kt
│ └── androidTest
│ │ └── java
│ │ └── sh
│ │ └── eliza
│ │ └── textbender
│ │ └── ExampleInstrumentedTest.kt
├── proguard-rules.pro
└── build.gradle.kts
├── images
├── demo-url.gif
├── demo-context.gif
├── demo-overlay.gif
└── demo-clipboard.gif
├── gradle
└── wrapper
│ ├── gradle-wrapper.jar
│ └── gradle-wrapper.properties
├── .dir-locals.el
├── shell.nix
├── settings.gradle.kts
├── .gitignore
├── NOTES.org
├── flake.lock
├── gradle.properties
├── flake.nix
├── gradlew.bat
├── icon.svg
├── README.md
├── gradlew
└── LICENSE
/.envrc:
--------------------------------------------------------------------------------
1 | use nix
2 |
--------------------------------------------------------------------------------
/app/.gitignore:
--------------------------------------------------------------------------------
1 | /build
--------------------------------------------------------------------------------
/images/demo-url.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/elizagamedev/android-textbender/HEAD/images/demo-url.gif
--------------------------------------------------------------------------------
/images/demo-context.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/elizagamedev/android-textbender/HEAD/images/demo-context.gif
--------------------------------------------------------------------------------
/images/demo-overlay.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/elizagamedev/android-textbender/HEAD/images/demo-overlay.gif
--------------------------------------------------------------------------------
/images/demo-clipboard.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/elizagamedev/android-textbender/HEAD/images/demo-clipboard.gif
--------------------------------------------------------------------------------
/gradle/wrapper/gradle-wrapper.jar:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/elizagamedev/android-textbender/HEAD/gradle/wrapper/gradle-wrapper.jar
--------------------------------------------------------------------------------
/app/src/main/ic_launcher-playstore.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/elizagamedev/android-textbender/HEAD/app/src/main/ic_launcher-playstore.png
--------------------------------------------------------------------------------
/.dir-locals.el:
--------------------------------------------------------------------------------
1 | ;;; Directory Local Variables
2 | ;;; For more information see (info "(emacs) Directory Variables")
3 |
4 | ((nil . ((compile-command . "cd \"$(git rev-parse --show-toplevel)\" && ./gradlew installDebug"))))
5 |
--------------------------------------------------------------------------------
/gradle/wrapper/gradle-wrapper.properties:
--------------------------------------------------------------------------------
1 | #Mon Nov 07 02:12:16 GMT 2022
2 | distributionBase=GRADLE_USER_HOME
3 | distributionUrl=https\://services.gradle.org/distributions/gradle-7.5-bin.zip
4 | distributionPath=wrapper/dists
5 | zipStorePath=wrapper/dists
6 | zipStoreBase=GRADLE_USER_HOME
7 |
--------------------------------------------------------------------------------
/app/src/main/res/drawable/floating_buttons_background.xml:
--------------------------------------------------------------------------------
1 |
2 |
4 |
5 |
6 |
7 |
--------------------------------------------------------------------------------
/app/src/main/res/drawable/button_textarea_pressed.xml:
--------------------------------------------------------------------------------
1 |
2 |
4 |
5 |
7 |
8 |
--------------------------------------------------------------------------------
/app/src/main/java/sh/eliza/textbender/DummyActivity.kt:
--------------------------------------------------------------------------------
1 | package sh.eliza.textbender
2 |
3 | import android.app.Activity
4 | import android.os.Bundle
5 |
6 | class DummyActivity : Activity() {
7 | override fun onCreate(savedInstanceState: Bundle?) {
8 | super.onCreate(savedInstanceState)
9 | finish()
10 | }
11 | }
12 |
--------------------------------------------------------------------------------
/shell.nix:
--------------------------------------------------------------------------------
1 | (import
2 | (
3 | let lock = builtins.fromJSON (builtins.readFile ./flake.lock); in
4 | fetchTarball {
5 | url = "https://github.com/edolstra/flake-compat/archive/${lock.nodes.flake-compat.locked.rev}.tar.gz";
6 | sha256 = lock.nodes.flake-compat.locked.narHash;
7 | }
8 | )
9 | { src = ./.; }
10 | ).shellNix
11 |
--------------------------------------------------------------------------------
/app/src/main/res/layout/settings_activity.xml:
--------------------------------------------------------------------------------
1 |
4 |
5 |
9 |
--------------------------------------------------------------------------------
/app/src/main/res/drawable/button_textarea_enabled.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
6 |
8 |
9 |
--------------------------------------------------------------------------------
/app/src/main/res/drawable/button_textarea_focused.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
6 |
8 |
9 |
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-anydpi/ic_launcher.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
--------------------------------------------------------------------------------
/settings.gradle.kts:
--------------------------------------------------------------------------------
1 | pluginManagement {
2 | repositories {
3 | google()
4 | mavenCentral()
5 | gradlePluginPortal()
6 | }
7 | }
8 |
9 | dependencyResolutionManagement {
10 | repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
11 | repositories {
12 | google()
13 | mavenCentral()
14 | }
15 | }
16 |
17 | rootProject.name = "Textbender"
18 |
19 | include(":app")
20 |
--------------------------------------------------------------------------------
/app/src/test/java/sh/eliza/textbender/ExampleUnitTest.kt:
--------------------------------------------------------------------------------
1 | package sh.eliza.textbender
2 |
3 | import org.junit.Test
4 |
5 | import org.junit.Assert.*
6 |
7 | /**
8 | * Example local unit test, which will execute on the development machine (host).
9 | *
10 | * See [testing documentation](http://d.android.com/tools/testing).
11 | */
12 | class ExampleUnitTest {
13 | @Test
14 | fun addition_isCorrect() {
15 | assertEquals(4, 2 + 2)
16 | }
17 | }
--------------------------------------------------------------------------------
/app/src/main/res/drawable/layers_24dp.xml:
--------------------------------------------------------------------------------
1 |
7 |
10 |
11 |
--------------------------------------------------------------------------------
/app/src/main/res/values/styles.xml:
--------------------------------------------------------------------------------
1 |
2 |
10 |
11 |
--------------------------------------------------------------------------------
/app/src/main/java/sh/eliza/textbender/Toaster.kt:
--------------------------------------------------------------------------------
1 | package sh.eliza.textbender
2 |
3 | import android.content.Context
4 | import android.os.Handler
5 | import android.os.Looper
6 | import android.widget.Toast
7 |
8 | /** All toasters toast toast. */
9 | class Toaster(private val context: Context) {
10 | private val mainHandler = Handler(Looper.getMainLooper())
11 |
12 | fun show(text: String, duration: Int) {
13 | mainHandler.post { Toast.makeText(context, text, duration).show() }
14 | }
15 | }
16 |
--------------------------------------------------------------------------------
/app/src/main/res/xml/backup_rules.xml:
--------------------------------------------------------------------------------
1 |
8 |
9 |
13 |
--------------------------------------------------------------------------------
/app/src/main/res/drawable/button_textarea.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
7 |
11 |
14 |
15 |
--------------------------------------------------------------------------------
/app/src/main/res/xml/accessibility_service_config.xml:
--------------------------------------------------------------------------------
1 |
11 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Gradle files
2 | .gradle/
3 | build/
4 |
5 | # Local configuration file (sdk path, etc)
6 | local.properties
7 |
8 | # Log/OS Files
9 | *.log
10 |
11 | # Android Studio generated files and folders
12 | captures/
13 | .externalNativeBuild/
14 | .cxx/
15 | *.apk
16 | output.json
17 |
18 | # IntelliJ
19 | *.iml
20 | .idea/
21 | misc.xml
22 | deploymentTargetDropDown.xml
23 | render.experimental.xml
24 |
25 | # Keystore files
26 | *.jks
27 | *.keystore
28 |
29 | # Google Services (e.g. APIs or Firebase)
30 | google-services.json
31 |
32 | # Android Profiling
33 | *.hprof
34 |
35 | # direnv
36 | /.direnv
37 |
38 | # Release builds
39 | app/release
40 |
--------------------------------------------------------------------------------
/app/src/main/res/xml/data_extraction_rules.xml:
--------------------------------------------------------------------------------
1 |
6 |
7 |
8 |
12 |
13 |
19 |
--------------------------------------------------------------------------------
/app/src/main/java/sh/eliza/textbender/BendClipboardTileService.kt:
--------------------------------------------------------------------------------
1 | package sh.eliza.textbender
2 |
3 | import android.content.Intent
4 | import android.service.quicksettings.Tile
5 |
6 | class BendClipboardTileService : TextbenderTileService() {
7 | override val desiredState: Int
8 | get() =
9 | if (preferencesSnapshot.clipboardDestination != TextbenderPreferences.Destination.DISABLED) {
10 | Tile.STATE_INACTIVE
11 | } else {
12 | Tile.STATE_UNAVAILABLE
13 | }
14 |
15 | override fun onClick() {
16 | super.onClick()
17 | startActivityAndCollapse(
18 | Intent(this, BendClipboardActivity::class.java).apply {
19 | flags = Intent.FLAG_ACTIVITY_NEW_TASK
20 | }
21 | )
22 | }
23 | }
24 |
--------------------------------------------------------------------------------
/app/src/main/res/drawable/ic_launcher_foreground.xml:
--------------------------------------------------------------------------------
1 |
6 |
10 |
14 |
15 |
16 |
--------------------------------------------------------------------------------
/app/src/androidTest/java/sh/eliza/textbender/ExampleInstrumentedTest.kt:
--------------------------------------------------------------------------------
1 | package sh.eliza.textbender
2 |
3 | import androidx.test.platform.app.InstrumentationRegistry
4 | import androidx.test.ext.junit.runners.AndroidJUnit4
5 |
6 | import org.junit.Test
7 | import org.junit.runner.RunWith
8 |
9 | import org.junit.Assert.*
10 |
11 | /**
12 | * Instrumented test, which will execute on an Android device.
13 | *
14 | * See [testing documentation](http://d.android.com/tools/testing).
15 | */
16 | @RunWith(AndroidJUnit4::class)
17 | class ExampleInstrumentedTest {
18 | @Test
19 | fun useAppContext() {
20 | // Context of the app under test.
21 | val appContext = InstrumentationRegistry.getInstrumentation().targetContext
22 | assertEquals("sh.eliza.textbender", appContext.packageName)
23 | }
24 | }
--------------------------------------------------------------------------------
/app/src/main/java/sh/eliza/textbender/FloatingButtonsTileService.kt:
--------------------------------------------------------------------------------
1 | package sh.eliza.textbender
2 |
3 | import android.service.quicksettings.Tile
4 |
5 | private const val TAG = "FloatingButtonsTileService"
6 |
7 | class FloatingButtonsTileService : TextbenderTileService() {
8 | override val desiredState: Int
9 | get() =
10 | if (serviceInstance === null || preferencesSnapshot.floatingButtonsEmpty) {
11 | Tile.STATE_UNAVAILABLE
12 | } else if (preferencesSnapshot.floatingButtonsEnabled) {
13 | Tile.STATE_ACTIVE
14 | } else {
15 | Tile.STATE_INACTIVE
16 | }
17 |
18 | override fun onClick() {
19 | super.onClick()
20 | if (serviceInstance !== null) {
21 | preferences.putFloatingButtonEnabled(!preferences.snapshot.floatingButtonsEnabled)
22 | }
23 | }
24 | }
25 |
--------------------------------------------------------------------------------
/app/proguard-rules.pro:
--------------------------------------------------------------------------------
1 | # Add project specific ProGuard rules here.
2 | # You can control the set of applied configuration files using the
3 | # proguardFiles setting in build.gradle.kts.
4 | #
5 | # For more details, see
6 | # http://developer.android.com/guide/developing/tools/proguard.html
7 |
8 | # If your project uses WebView with JS, uncomment the following
9 | # and specify the fully qualified class name to the JavaScript interface
10 | # class:
11 | #-keepclassmembers class fqcn.of.javascript.interface.for.webview {
12 | # public *;
13 | #}
14 |
15 | # Uncomment this to preserve the line number information for
16 | # debugging stack traces.
17 | #-keepattributes SourceFile,LineNumberTable
18 |
19 | # If you keep the line number information, uncomment this to
20 | # hide the original source file name.
21 | #-renamesourcefileattribute SourceFile
--------------------------------------------------------------------------------
/NOTES.org:
--------------------------------------------------------------------------------
1 | * Updating SDK/build tools version
2 | - app/build.gradle
3 | - ~compileSdk~
4 | - ~buildToolsVersion~
5 | - ~targetSdk~
6 | - ~sourceCompatibility~
7 | - ~targetCompatibility~
8 | - ~jvmTarget~
9 | - app/src/main/AndroidManifest.xml
10 | - ~targetApi~
11 | - flake.nix
12 | * Releasing a new version
13 | - app/build.gradle
14 | - ~versionCode~
15 | - ~versionName~
16 | - Sign app
17 | #+begin_src sh
18 | ./gradlew assembleRelease
19 | rm -rf app/release && mkdir -p app/release
20 | zipalign -v -p 4 app/build/outputs/apk/release/app-release-unsigned.apk app/release/app-release-unsigned-aligned.apk
21 | apksigner sign --ks ~/.AndroidKeyStore/textbender-fdroid.jks --out app/release/app-release.apk app/release/app-release-unsigned-aligned.apk
22 | apksigner verify app/release/app-release.apk
23 | #+end_src
24 |
--------------------------------------------------------------------------------
/app/src/main/res/drawable/content_paste_go_24dp.xml:
--------------------------------------------------------------------------------
1 |
7 |
10 |
11 |
--------------------------------------------------------------------------------
/app/src/main/res/drawable/ic_launcher_background.xml:
--------------------------------------------------------------------------------
1 |
7 |
9 |
11 |
17 |
20 |
23 |
24 |
25 |
26 |
--------------------------------------------------------------------------------
/app/src/main/java/sh/eliza/textbender/ActivateOverlayTileService.kt:
--------------------------------------------------------------------------------
1 | package sh.eliza.textbender
2 |
3 | import android.content.Intent
4 | import android.service.quicksettings.Tile
5 | import android.widget.Toast
6 |
7 | class ActivateOverlayTileService : TextbenderTileService() {
8 | private val toaster = Toaster(this)
9 |
10 | override val desiredState: Int
11 | get() =
12 | if (serviceInstance === null) {
13 | Tile.STATE_UNAVAILABLE
14 | } else {
15 | Tile.STATE_INACTIVE
16 | }
17 |
18 | override fun onClick() {
19 | super.onClick()
20 | val serviceInstance = serviceInstance
21 | if (serviceInstance !== null) {
22 | serviceInstance.openOverlay(500L, showToast = false)
23 | startActivityAndCollapse(
24 | Intent(this, DummyActivity::class.java).apply { flags = Intent.FLAG_ACTIVITY_NEW_TASK }
25 | )
26 | } else {
27 | toaster.show(getString(R.string.could_not_access_accessibility_service), Toast.LENGTH_SHORT)
28 | }
29 | }
30 | }
31 |
--------------------------------------------------------------------------------
/app/src/main/res/drawable/directions_boat_24dp.xml:
--------------------------------------------------------------------------------
1 |
7 |
10 |
11 |
--------------------------------------------------------------------------------
/app/src/main/res/layout/floating_buttons.xml:
--------------------------------------------------------------------------------
1 |
2 |
8 |
9 |
16 |
17 |
24 |
25 |
26 |
--------------------------------------------------------------------------------
/flake.lock:
--------------------------------------------------------------------------------
1 | {
2 | "nodes": {
3 | "flake-compat": {
4 | "flake": false,
5 | "locked": {
6 | "lastModified": 1673956053,
7 | "narHash": "sha256-4gtG9iQuiKITOjNQQeQIpoIB6b16fm+504Ch3sNKLd8=",
8 | "owner": "edolstra",
9 | "repo": "flake-compat",
10 | "rev": "35bb57c0c8d8b62bbfd284272c928ceb64ddbde9",
11 | "type": "github"
12 | },
13 | "original": {
14 | "owner": "edolstra",
15 | "repo": "flake-compat",
16 | "type": "github"
17 | }
18 | },
19 | "nixpkgs": {
20 | "locked": {
21 | "lastModified": 1684844536,
22 | "narHash": "sha256-M7HhXYVqAuNb25r/d3FOO0z4GxPqDIZp5UjHFbBgw0Q=",
23 | "owner": "NixOS",
24 | "repo": "nixpkgs",
25 | "rev": "d30264c2691128adc261d7c9388033645f0e742b",
26 | "type": "github"
27 | },
28 | "original": {
29 | "owner": "NixOS",
30 | "ref": "nixos-unstable",
31 | "repo": "nixpkgs",
32 | "type": "github"
33 | }
34 | },
35 | "root": {
36 | "inputs": {
37 | "flake-compat": "flake-compat",
38 | "nixpkgs": "nixpkgs"
39 | }
40 | }
41 | },
42 | "root": "root",
43 | "version": 7
44 | }
45 |
--------------------------------------------------------------------------------
/app/src/main/java/sh/eliza/textbender/BendClipboardActivity.kt:
--------------------------------------------------------------------------------
1 | package sh.eliza.textbender
2 |
3 | import android.content.ClipboardManager
4 | import android.content.Context
5 | import android.content.Intent
6 | import android.widget.Toast
7 | import androidx.appcompat.app.AppCompatActivity
8 |
9 | class BendClipboardActivity : AppCompatActivity() {
10 | private val toaster = Toaster(this)
11 |
12 | override fun onWindowFocusChanged(hasFocus: Boolean) {
13 | val preferences = TextbenderPreferences.getInstance(this).snapshot
14 | if (hasFocus) {
15 | val text =
16 | (getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager)
17 | .primaryClip
18 | ?.getItemAt(0)
19 | ?.coerceToText(this)
20 | if (preferences.clipboardDestination == TextbenderPreferences.Destination.DISABLED) {
21 | toaster.show(getString(R.string.clipboard_not_configured), Toast.LENGTH_SHORT)
22 | startActivity(
23 | Intent(this, SettingsActivity::class.java).apply { flags = Intent.FLAG_ACTIVITY_NEW_TASK }
24 | )
25 | } else {
26 | Textbender.handleText(this, toaster, preferences, preferences.clipboardDestination, text)
27 | }
28 | finish()
29 | }
30 | }
31 | }
32 |
--------------------------------------------------------------------------------
/app/build.gradle.kts:
--------------------------------------------------------------------------------
1 | plugins {
2 | id("com.android.application")
3 | id("org.jetbrains.kotlin.android")
4 | }
5 |
6 | android {
7 | namespace = "sh.eliza.textbender"
8 | compileSdk = 33
9 | buildToolsVersion = "33.0.2"
10 |
11 | defaultConfig {
12 | applicationId = "sh.eliza.textbender"
13 | minSdk = 26
14 | targetSdk = 33
15 | versionCode = 4
16 | versionName = "0.3.1"
17 |
18 | testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
19 | }
20 |
21 | buildTypes {
22 | release {
23 | isMinifyEnabled = false
24 | proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
25 | }
26 | }
27 | compileOptions {
28 | sourceCompatibility = JavaVersion.VERSION_11
29 | targetCompatibility = JavaVersion.VERSION_11
30 | }
31 | kotlinOptions { jvmTarget = "11" }
32 | }
33 |
34 | dependencies {
35 | implementation("androidx.core:core-ktx:1.10.1")
36 | implementation("androidx.appcompat:appcompat:1.6.1")
37 | implementation("com.google.android.material:material:1.9.0")
38 | implementation("androidx.lifecycle:lifecycle-viewmodel-ktx:2.6.1")
39 | implementation("androidx.preference:preference:1.2.0")
40 | testImplementation("junit:junit:4.13.2")
41 | androidTestImplementation("androidx.test.ext:junit:1.1.5")
42 | androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1")
43 | }
44 |
--------------------------------------------------------------------------------
/gradle.properties:
--------------------------------------------------------------------------------
1 | # Project-wide Gradle settings.
2 | # IDE (e.g. Android Studio) users:
3 | # Gradle settings configured through the IDE *will override*
4 | # any settings specified in this file.
5 | # For more details on how to configure your build environment visit
6 | # http://www.gradle.org/docs/current/userguide/build_environment.html
7 | # Specifies the JVM arguments used for the daemon process.
8 | # The setting is particularly useful for tweaking memory settings.
9 | org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
10 | # When configured, Gradle will run in incubating parallel mode.
11 | # This option should only be used with decoupled projects. More details, visit
12 | # http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects
13 | # org.gradle.parallel=true
14 | # AndroidX package structure to make it clearer which packages are bundled with the
15 | # Android operating system, and which are packaged with your app's APK
16 | # https://developer.android.com/topic/libraries/support-library/androidx-rn
17 | android.useAndroidX=true
18 | # Kotlin code style for this project: "official" or "obsolete":
19 | kotlin.code.style=official
20 | # Enables namespacing of each library's R class so that its R class includes only the
21 | # resources declared in the library itself and none from the library's dependencies,
22 | # thereby reducing the size of the R class for that library
23 | android.nonTransitiveRClass=true
--------------------------------------------------------------------------------
/app/src/main/java/sh/eliza/textbender/OcclusionBuffer.kt:
--------------------------------------------------------------------------------
1 | package sh.eliza.textbender
2 |
3 | class OcclusionBuffer {
4 | /** List of disjoint rects representing occluded areas. */
5 | private val buffer = mutableListOf()
6 |
7 | /** Returns true if the given rect is at least partially visible. */
8 | fun isPartiallyVisible(rect: ImmutableRect): Boolean {
9 | if (buffer.any { it.contains(rect) }) {
10 | return false
11 | }
12 | val subrects =
13 | buffer.map { rect.difference(it) }.firstOrNull { !it.isEmpty() && it.first() != rect }
14 | if (subrects === null) {
15 | // It's completely disjoint from the rects in the buffer.
16 | return true
17 | }
18 | for (subrect in subrects) {
19 | if (isPartiallyVisible(subrect)) {
20 | return true
21 | }
22 | }
23 | return false
24 | }
25 |
26 | /** Occludes the area of the given rect and returns true it's at least partially visible. */
27 | fun add(rect: ImmutableRect): Boolean {
28 | if (buffer.any { it.contains(rect) }) {
29 | // Fully occluded by another rect.
30 | return false
31 | }
32 | val subrects =
33 | buffer.map { rect.difference(it) }.firstOrNull { !it.isEmpty() && it.first() != rect }
34 | if (subrects === null) {
35 | // It's completely disjoint from the rects in the buffer.
36 | buffer.add(rect)
37 | return true
38 | }
39 | // Add each of the subdivided rects individually.
40 | var isPartiallyVisible = false
41 | for (subrect in subrects) {
42 | if (add(subrect)) {
43 | isPartiallyVisible = true
44 | }
45 | }
46 | return isPartiallyVisible
47 | }
48 |
49 | fun clear() = buffer.clear()
50 | }
51 |
--------------------------------------------------------------------------------
/app/src/main/java/sh/eliza/textbender/ProcessTextActivity.kt:
--------------------------------------------------------------------------------
1 | package sh.eliza.textbender
2 |
3 | import android.app.Activity
4 | import android.content.Intent
5 | import android.os.Bundle
6 | import java.net.URLDecoder
7 |
8 | private const val URL_PREFIX = "textbender://x?x="
9 |
10 | class ProcessTextActivity : Activity() {
11 | private val toaster = Toaster(this)
12 |
13 | override fun onCreate(savedInstanceState: Bundle?) {
14 | super.onCreate(savedInstanceState)
15 |
16 | val preferences = TextbenderPreferences.getInstance(applicationContext).snapshot
17 |
18 | when (intent.action) {
19 | Intent.ACTION_PROCESS_TEXT -> {
20 | val text = intent.getCharSequenceExtra(Intent.EXTRA_PROCESS_TEXT)
21 | Textbender.handleText(
22 | applicationContext,
23 | toaster,
24 | preferences,
25 | preferences.globalContextMenuDestination,
26 | text
27 | )
28 | }
29 | Intent.ACTION_SEND -> {
30 | val text = intent.getCharSequenceExtra(Intent.EXTRA_TEXT)
31 | Textbender.handleText(
32 | applicationContext,
33 | toaster,
34 | preferences,
35 | preferences.shareDestination,
36 | text
37 | )
38 | }
39 | Intent.ACTION_VIEW -> {
40 | val url = intent.data.toString()
41 | if (url.lowercase().startsWith(URL_PREFIX)) {
42 | val text = URLDecoder.decode(url.substring(URL_PREFIX.length), Charsets.UTF_8.name())
43 | Textbender.handleText(
44 | applicationContext,
45 | toaster,
46 | preferences,
47 | preferences.urlDestination,
48 | text
49 | )
50 | }
51 | }
52 | }
53 |
54 | finish()
55 | }
56 | }
57 |
--------------------------------------------------------------------------------
/app/src/main/java/sh/eliza/textbender/ImmutableRect.kt:
--------------------------------------------------------------------------------
1 | package sh.eliza.textbender
2 |
3 | import android.graphics.Rect
4 | import android.graphics.RectF
5 |
6 | class ImmutableRect(private val rect: Rect) {
7 | constructor(
8 | left: Int,
9 | top: Int,
10 | right: Int,
11 | bottom: Int
12 | ) : this(Rect(left, top, right, bottom)) {}
13 |
14 | constructor(rectf: RectF) : this(Rect().apply { rectf.round(this) }) {}
15 |
16 | val left = rect.left
17 | val top = rect.top
18 | val right = rect.right
19 | val bottom = rect.bottom
20 | val width = rect.width()
21 | val height = rect.height()
22 |
23 | val isEmpty: Boolean
24 | get() = rect.isEmpty()
25 |
26 | fun intersects(other: ImmutableRect) = Rect.intersects(rect, other.rect)
27 |
28 | fun intersect(other: ImmutableRect) =
29 | if (intersects(other)) {
30 | ImmutableRect(Rect().apply { @Suppress("CheckResult") setIntersect(rect, other.rect) })
31 | } else {
32 | null
33 | }
34 |
35 | fun union(other: ImmutableRect) = Rect(rect).apply { union(other.rect) }
36 |
37 | fun difference(other: ImmutableRect): List {
38 | if (other.width == 0 || other.height == 0 || other.contains(this)) {
39 | return listOf()
40 | }
41 | return listOf(
42 | // Greedy left.
43 | ImmutableRect(left, top, other.left, bottom),
44 | // Greedy right.
45 | ImmutableRect(other.right, top, right, bottom),
46 | // Shy top.
47 | ImmutableRect(other.left, top, other.right, other.top),
48 | // Shy bottom.
49 | ImmutableRect(other.left, other.bottom, other.right, bottom),
50 | )
51 | .filter { !it.isEmpty && contains(it) }
52 | }
53 |
54 | fun offset(dx: Int, dy: Int) = ImmutableRect(Rect(rect).apply { offset(dx, dy) })
55 |
56 | fun contains(other: ImmutableRect) = rect.contains(other.rect)
57 |
58 | fun inset(dxy: Int) = ImmutableRect(Rect(rect).apply { inset(dxy, dxy) })
59 |
60 | override fun equals(other: Any?) = rect.equals((other as? ImmutableRect)?.rect)
61 |
62 | override fun hashCode() = rect.hashCode()
63 |
64 | override fun toString() = rect.toString()
65 | }
66 |
--------------------------------------------------------------------------------
/flake.nix:
--------------------------------------------------------------------------------
1 | # This flake is based on the lovely example here:
2 | # https://github.com/fcitx5-android/fcitx5-android/blob/master/flake.nix
3 | {
4 | description = "Dev shell flake for textbender";
5 |
6 | inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
7 | inputs.flake-compat = {
8 | url = "github:edolstra/flake-compat";
9 | flake = false;
10 | };
11 |
12 | outputs = { self, nixpkgs, ... }:
13 | let
14 | pkgs = import nixpkgs {
15 | system = "x86_64-linux";
16 | config.android_sdk.accept_license = true;
17 | config.allowUnfree = true;
18 | overlays = [ self.overlays.default ];
19 | };
20 | in
21 | with pkgs;
22 | with textbender-android-sdk;
23 | {
24 |
25 | devShells.x86_64-linux.default =
26 | let
27 | build-tools = "${androidComposition.androidsdk}/libexec/android-sdk/build-tools/${buildToolsVersion}";
28 | in
29 | mkShell {
30 | buildInputs = [
31 | androidComposition.androidsdk
32 | androidStudioPackages.beta
33 | kotlin-language-server
34 | ];
35 | ANDROID_SDK_ROOT =
36 | "${androidComposition.androidsdk}/libexec/android-sdk";
37 | GRADLE_OPTS =
38 | "-Dorg.gradle.project.android.aapt2FromMavenOverride=${build-tools}/aapt2";
39 | JAVA_HOME = "${jdk11}";
40 | shellHook = ''
41 | echo sdk.dir=$ANDROID_SDK_ROOT > local.properties
42 | export PATH="$PATH:${build-tools}"
43 | '';
44 | };
45 | } // {
46 | overlays.default = final: prev: {
47 | textbender-android-sdk = rec {
48 | buildToolsVersion = "33.0.2";
49 | androidComposition = prev.androidenv.composeAndroidPackages {
50 | platformToolsVersion = "34.0.1";
51 | buildToolsVersions = [ buildToolsVersion ];
52 | platformVersions = [ "33" ];
53 | abiVersions = [ "arm64-v8a" "armeabi-v7a" ];
54 | includeNDK = false;
55 | includeEmulator = false;
56 | useGoogleAPIs = false;
57 | };
58 | };
59 | };
60 | };
61 | }
62 |
--------------------------------------------------------------------------------
/app/src/main/java/sh/eliza/textbender/TextbenderTileService.kt:
--------------------------------------------------------------------------------
1 | package sh.eliza.textbender
2 |
3 | import android.os.Build
4 | import android.os.Handler
5 | import android.os.Looper
6 | import android.service.quicksettings.TileService
7 |
8 | abstract class TextbenderTileService : TileService() {
9 | protected lateinit var preferences: TextbenderPreferences
10 | protected var preferencesSnapshot: TextbenderPreferences.Snapshot
11 | get() = preferencesSnapshotField ?: preferences.defaults
12 | set(value) {
13 | preferencesSnapshotField = value
14 | }
15 | private var preferencesSnapshotField: TextbenderPreferences.Snapshot? = null
16 | protected var serviceInstance: TextbenderService? = null
17 |
18 | private val handler = Handler(Looper.getMainLooper())
19 |
20 | abstract val desiredState: Int
21 |
22 | override fun onCreate() {
23 | super.onCreate()
24 | preferences = TextbenderPreferences.getInstance(applicationContext)
25 | }
26 |
27 | override fun onStartListening() {
28 | super.onStartListening()
29 | if (Build.VERSION.SDK_INT >= 29) {
30 | qsTile.subtitle = getString(R.string.app_name)
31 | }
32 |
33 | preferences.addOnChangeListener(this::onPreferenceChanged, handler)
34 | TextbenderService.addOnInstanceChangedListener(this::onServiceInstanceChanged, handler)
35 |
36 | preferencesSnapshot = preferences.snapshot
37 | serviceInstance = TextbenderService.instance
38 | updateState()
39 | }
40 |
41 | override fun onStopListening() {
42 | super.onStopListening()
43 |
44 | TextbenderService.removeOnInstanceChangedListener(this::onServiceInstanceChanged)
45 | preferences.removeOnChangeListener(this::onPreferenceChanged)
46 | }
47 |
48 | private fun onPreferenceChanged(preferencesSnapshot: TextbenderPreferences.Snapshot) {
49 | this.preferencesSnapshot = preferencesSnapshot
50 | updateState()
51 | }
52 |
53 | private fun onServiceInstanceChanged(serviceInstance: TextbenderService?) {
54 | this.serviceInstance = serviceInstance
55 | updateState()
56 | }
57 |
58 | private fun updateState() {
59 | qsTile.run {
60 | state = desiredState
61 | updateTile()
62 | }
63 | }
64 | }
65 |
--------------------------------------------------------------------------------
/app/src/main/res/values-night/themes.xml:
--------------------------------------------------------------------------------
1 |
2 |
30 |
31 |
--------------------------------------------------------------------------------
/app/src/main/res/values/arrays.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | - @string/fingerprint_gesture_disabled
5 | - @string/fingerprint_gesture_down
6 | - @string/fingerprint_gesture_left
7 | - @string/fingerprint_gesture_right
8 | - @string/fingerprint_gesture_up
9 |
10 |
11 |
12 | - disabled
13 | - down
14 | - left
15 | - right
16 | - up
17 |
18 |
19 |
20 |
21 | - @string/destination_disabled
22 | - @string/destination_clipboard
23 | - @string/destination_url
24 | - @string/destination_share
25 | - @string/destination_pleco
26 | - @string/destination_yomichan
27 |
28 |
29 |
30 | - disabled
31 | - clipboard
32 | - url
33 | - share
34 | - pleco
35 | - yomichan
36 |
37 |
38 |
39 | - @string/destination_disabled
40 | - @string/destination_clipboard
41 | - @string/destination_url
42 | - @string/destination_pleco
43 | - @string/destination_yomichan
44 |
45 |
46 |
47 | - disabled
48 | - clipboard
49 | - url
50 | - pleco
51 | - yomichan
52 |
53 |
54 |
55 | - @string/destination_disabled
56 | - @string/destination_url
57 | - @string/destination_share
58 | - @string/destination_pleco
59 | - @string/destination_yomichan
60 |
61 |
62 |
63 | - disabled
64 | - url
65 | - share
66 | - pleco
67 | - yomichan
68 |
69 |
70 |
--------------------------------------------------------------------------------
/gradlew.bat:
--------------------------------------------------------------------------------
1 | @rem
2 | @rem Copyright 2015 the original author or authors.
3 | @rem
4 | @rem Licensed under the Apache License, Version 2.0 (the "License");
5 | @rem you may not use this file except in compliance with the License.
6 | @rem You may obtain a copy of the License at
7 | @rem
8 | @rem https://www.apache.org/licenses/LICENSE-2.0
9 | @rem
10 | @rem Unless required by applicable law or agreed to in writing, software
11 | @rem distributed under the License is distributed on an "AS IS" BASIS,
12 | @rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | @rem See the License for the specific language governing permissions and
14 | @rem limitations under the License.
15 | @rem
16 |
17 | @if "%DEBUG%" == "" @echo off
18 | @rem ##########################################################################
19 | @rem
20 | @rem Gradle startup script for Windows
21 | @rem
22 | @rem ##########################################################################
23 |
24 | @rem Set local scope for the variables with windows NT shell
25 | if "%OS%"=="Windows_NT" setlocal
26 |
27 | set DIRNAME=%~dp0
28 | if "%DIRNAME%" == "" set DIRNAME=.
29 | set APP_BASE_NAME=%~n0
30 | set APP_HOME=%DIRNAME%
31 |
32 | @rem Resolve any "." and ".." in APP_HOME to make it shorter.
33 | for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
34 |
35 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
36 | set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
37 |
38 | @rem Find java.exe
39 | if defined JAVA_HOME goto findJavaFromJavaHome
40 |
41 | set JAVA_EXE=java.exe
42 | %JAVA_EXE% -version >NUL 2>&1
43 | if "%ERRORLEVEL%" == "0" goto execute
44 |
45 | echo.
46 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
47 | echo.
48 | echo Please set the JAVA_HOME variable in your environment to match the
49 | echo location of your Java installation.
50 |
51 | goto fail
52 |
53 | :findJavaFromJavaHome
54 | set JAVA_HOME=%JAVA_HOME:"=%
55 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe
56 |
57 | if exist "%JAVA_EXE%" goto execute
58 |
59 | echo.
60 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
61 | echo.
62 | echo Please set the JAVA_HOME variable in your environment to match the
63 | echo location of your Java installation.
64 |
65 | goto fail
66 |
67 | :execute
68 | @rem Setup the command line
69 |
70 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
71 |
72 |
73 | @rem Execute Gradle
74 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
75 |
76 | :end
77 | @rem End local scope for the variables with windows NT shell
78 | if "%ERRORLEVEL%"=="0" goto mainEnd
79 |
80 | :fail
81 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
82 | rem the _cmd.exe /c_ return code!
83 | if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
84 | exit /b 1
85 |
86 | :mainEnd
87 | if "%OS%"=="Windows_NT" endlocal
88 |
89 | :omega
90 |
--------------------------------------------------------------------------------
/app/src/main/res/values/colors.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | #9B4054
5 | #FFFFFF
6 | #FFD9DE
7 | #400014
8 | #75565B
9 | #FFFFFF
10 | #FFD9DE
11 | #2C1519
12 | #6C5E00
13 | #FFFFFF
14 | #FBE365
15 | #211B00
16 | #BA1A1A
17 | #FFDAD6
18 | #FFFFFF
19 | #410002
20 | #FFFBFF
21 | #201A1B
22 | #FFFBFF
23 | #201A1B
24 | #F3DDDF
25 | #524345
26 | #847375
27 | #FBEEEE
28 | #362F2F
29 | #FFB2BE
30 |
31 |
32 |
33 |
34 | #FFB2BE
35 | #5F1128
36 | #7D293D
37 | #FFD9DE
38 | #E5BDC2
39 | #43292D
40 | #5C3F43
41 | #FFD9DE
42 | #DDC74D
43 | #383000
44 | #524700
45 | #FBE365
46 | #FFB4AB
47 | #93000A
48 | #690005
49 | #FFDAD6
50 | #201A1B
51 | #ECE0E0
52 | #201A1B
53 | #ECE0E0
54 | #524345
55 | #D6C2C3
56 | #9F8C8E
57 | #201A1B
58 | #ECE0E0
59 | #9B4054
60 |
61 |
62 |
63 |
64 |
65 |
--------------------------------------------------------------------------------
/app/src/main/java/sh/eliza/textbender/Extensions.kt:
--------------------------------------------------------------------------------
1 | package sh.eliza.textbender
2 |
3 | import android.graphics.Rect
4 | import android.graphics.RectF
5 | import android.os.Build
6 | import android.os.Bundle
7 | import android.util.Log
8 | import android.view.View
9 | import android.view.accessibility.AccessibilityNodeInfo
10 | import android.view.accessibility.AccessibilityWindowInfo
11 | import kotlin.math.min
12 |
13 | private const val TAG = "Extensions"
14 |
15 | /** List of children. */
16 | val AccessibilityNodeInfo.children: ArrayList
17 | get() {
18 | val childCount = childCount
19 | val list = ArrayList(childCount)
20 | for (i in 0 until childCount) {
21 | getChild(i)?.let { list.add(it) }
22 | }
23 | return list
24 | }
25 |
26 | val AccessibilityNodeInfo.boundsInScreen: ImmutableRect
27 | get() = ImmutableRect(Rect().apply { getBoundsInScreen(this) })
28 |
29 | val AccessibilityNodeInfo.textSizeInPx: Float?
30 | get() =
31 | if (Build.VERSION.SDK_INT >= 30) {
32 | refreshWithExtraData(AccessibilityNodeInfo.EXTRA_DATA_RENDERING_INFO_KEY, Bundle())
33 | extraRenderingInfo?.textSizeInPx
34 | } else {
35 | null
36 | }
37 |
38 | val AccessibilityNodeInfo.textBounds: ImmutableRect?
39 | get() {
40 | if (Build.VERSION.SDK_INT < 31) {
41 | return null
42 | }
43 | refreshWithExtraData(
44 | AccessibilityNodeInfo.EXTRA_DATA_TEXT_CHARACTER_LOCATION_KEY,
45 | Bundle().apply {
46 | putInt(AccessibilityNodeInfo.EXTRA_DATA_TEXT_CHARACTER_LOCATION_ARG_START_INDEX, 0)
47 | putInt(
48 | AccessibilityNodeInfo.EXTRA_DATA_TEXT_CHARACTER_LOCATION_ARG_LENGTH,
49 | min(AccessibilityNodeInfo.EXTRA_DATA_TEXT_CHARACTER_LOCATION_ARG_MAX_LENGTH, text.length)
50 | )
51 | }
52 | )
53 | val array =
54 | if (Build.VERSION.SDK_INT >= 33) {
55 | extras.getParcelableArray(
56 | AccessibilityNodeInfo.EXTRA_DATA_TEXT_CHARACTER_LOCATION_KEY,
57 | RectF::class.java
58 | )
59 | } else {
60 | @Suppress("DEPRECATION")
61 | extras
62 | .getParcelableArray(AccessibilityNodeInfo.EXTRA_DATA_TEXT_CHARACTER_LOCATION_KEY)
63 | ?.map { it as RectF }
64 | ?.toTypedArray()
65 | }
66 | if (array === null || array.all { it === null }) {
67 | return null
68 | }
69 | val result = RectF()
70 | for (rect in array) {
71 | if (rect !== null) {
72 | result.union(rect)
73 | }
74 | }
75 | return ImmutableRect(result)
76 | }
77 |
78 | fun AccessibilityNodeInfo.find(
79 | predicate: (AccessibilityNodeInfo) -> Boolean
80 | ): AccessibilityNodeInfo? {
81 | if (predicate(this)) {
82 | return this
83 | }
84 | val childCount = childCount
85 | for (i in 0 until childCount) {
86 | val result = getChild(i)?.find(predicate)
87 | if (result !== null) {
88 | return result
89 | }
90 | }
91 | return null
92 | }
93 |
94 | val View.boundsInScreen: ImmutableRect
95 | get() {
96 | val location = intArrayOf(0, 0).apply { getLocationOnScreen(this) }
97 | return ImmutableRect(location[0], location[1], location[0] + width, location[1] + height)
98 | }
99 |
100 | /* Debug functions */
101 | fun AccessibilityNodeInfo.dump(prefix: String) {
102 | val myPrefix = "$prefix/$className"
103 | if (text !== null) {
104 | Log.d(TAG, "$myPrefix: $text")
105 | }
106 | for (child in children) {
107 | child.dump(myPrefix)
108 | }
109 | }
110 |
111 | fun AccessibilityNodeInfo.grep(needle: String) {
112 | val text = text
113 | if (text !== null && text.contains(needle)) {
114 | Log.d(TAG, "found: text = $text viewIdResourceName = $viewIdResourceName")
115 | }
116 | for (child in children) {
117 | child.grep(needle)
118 | }
119 | }
120 |
121 | fun AccessibilityWindowInfo.dump() {
122 | val root = root
123 | if (root !== null) {
124 | root.dump("$title -- ")
125 | }
126 | }
127 |
--------------------------------------------------------------------------------
/icon.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
117 |
--------------------------------------------------------------------------------
/app/src/main/res/values/themes.xml:
--------------------------------------------------------------------------------
1 |
2 |
30 |
31 |
59 |
60 |
--------------------------------------------------------------------------------
/app/src/main/java/sh/eliza/textbender/Textbender.kt:
--------------------------------------------------------------------------------
1 | package sh.eliza.textbender
2 |
3 | import android.content.ClipData
4 | import android.content.ClipboardManager
5 | import android.content.Context
6 | import android.content.Intent
7 | import android.net.Uri
8 | import android.util.Log
9 | import android.widget.Toast
10 | import java.net.URLEncoder
11 | import kotlin.text.Regex
12 | import kotlin.text.RegexOption
13 |
14 | private const val TAG = "Textbender"
15 |
16 | object Textbender {
17 | fun handleText(
18 | context: Context,
19 | toaster: Toaster,
20 | preferences: TextbenderPreferences.Snapshot,
21 | destination: TextbenderPreferences.Destination,
22 | text: CharSequence?
23 | ) {
24 | val stripRegexp =
25 | if (preferences.stripRegexp.isEmpty()) {
26 | null
27 | } else {
28 | try {
29 | Regex(preferences.stripRegexp, setOf(RegexOption.MULTILINE, RegexOption.IGNORE_CASE))
30 | } catch (t: Throwable) {
31 | toaster.show(context.getString(R.string.invalid_regexp, t.message), Toast.LENGTH_LONG)
32 | null
33 | }
34 | }
35 | val strippedText =
36 | if (stripRegexp === null || text === null) {
37 | text
38 | } else {
39 | stripRegexp.replace(text, "")
40 | }
41 | if (strippedText.isNullOrEmpty()) {
42 | toaster.show(context.getString(R.string.text_empty), Toast.LENGTH_SHORT)
43 | return
44 | }
45 | when (destination) {
46 | TextbenderPreferences.Destination.DISABLED -> {}
47 | TextbenderPreferences.Destination.CLIPBOARD -> {
48 | val clipboardManager =
49 | context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
50 | val clipData = ClipData.newPlainText(context.getString(R.string.app_name), text)
51 | clipboardManager.setPrimaryClip(clipData)
52 | }
53 | TextbenderPreferences.Destination.URL -> {
54 | val uriText = URLEncoder.encode(strippedText.toString(), Charsets.UTF_8.name())
55 | val uri = Uri.parse(preferences.urlFormat.replace("{text}", uriText))
56 | openUri(context, toaster, uri)
57 | }
58 | TextbenderPreferences.Destination.SHARE -> {
59 | val intent =
60 | Intent(Intent.ACTION_SEND).apply {
61 | flags = Intent.FLAG_ACTIVITY_NEW_TASK
62 | setType("text/plain")
63 | putExtra(Intent.EXTRA_TEXT, strippedText)
64 | }
65 | context.startActivity(
66 | Intent.createChooser(intent, context.getString(R.string.app_name)).apply {
67 | flags = Intent.FLAG_ACTIVITY_NEW_TASK
68 | }
69 | )
70 | }
71 | TextbenderPreferences.Destination.PLECO -> {
72 | val uriText = URLEncoder.encode(strippedText.toString(), Charsets.UTF_8.name())
73 | val uri = Uri.parse("plecoapi://x-callback-url/s?q=$uriText")
74 | openUri(context, toaster, uri)
75 | }
76 | TextbenderPreferences.Destination.YOMICHAN -> openInYomichan(context, toaster, strippedText)
77 | }
78 | }
79 | }
80 |
81 | private fun openUri(context: Context, toaster: Toaster, uri: Uri) {
82 | Log.i(TAG, "Opening URI: ${uri}")
83 | val intent = Intent(Intent.ACTION_VIEW, uri).apply { flags = Intent.FLAG_ACTIVITY_NEW_TASK }
84 | if (intent.resolveActivity(context.packageManager) !== null) {
85 | context.startActivity(intent)
86 | } else {
87 | toaster.show(context.getString(R.string.could_not_open_uri, uri.toString()), Toast.LENGTH_LONG)
88 | }
89 | }
90 |
91 | private fun openInYomichan(context: Context, toaster: Toaster, text: CharSequence) {
92 | // Launch Kiwi browser.
93 | val uri = Uri.parse("googlechrome://navigate?url=")
94 | val intent =
95 | Intent(Intent.ACTION_VIEW, uri).apply {
96 | setPackage("com.kiwibrowser.browser")
97 | flags = Intent.FLAG_ACTIVITY_NEW_TASK
98 | }
99 | if (intent.resolveActivity(context.packageManager) !== null) {
100 | context.startActivity(intent)
101 | } else {
102 | toaster.show(context.getString(R.string.could_not_open_kiwi_browser), Toast.LENGTH_SHORT)
103 | return
104 | }
105 | val service = TextbenderService.instance
106 | if (service !== null) {
107 | service.openYomichan(text)
108 | } else {
109 | toaster.show(
110 | context.getString(R.string.could_not_access_accessibility_service),
111 | Toast.LENGTH_SHORT
112 | )
113 | return
114 | }
115 | }
116 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Textbender
2 |
3 | Bend Android text to your whim.
4 |
5 | ## Overview
6 |
7 | Textbender is an Android app which lets you shuffle text from various sources to
8 | various sources. In more concrete terms, it lets you do things like turn all
9 | text on your screen into buttons you can press to open them in a dictionary like
10 | [Yomichan](https://foosoft.net/projects/yomichan/) or
11 | [Pleco](https://www.pleco.com/).
12 |
13 | ### Feature Overview
14 |
15 | The most notable feature is the ability to retrieve any text on the screen
16 | visible to the accessibility API.
17 |
18 | 
19 |
20 | You can also access text from other sources; another commonly used one might be
21 | via the context menu.
22 |
23 | 
24 |
25 | Some apps don’t play nice with Android and only allow you to copy text.
26 | Textbender has that covered:
27 |
28 | 
29 |
30 | You can also do things like integrate Textbender with your Anki notes to open a
31 | word in Yomichan.
32 |
33 | 
34 |
35 | There’s more you can do that isn’t demonstrated in this list; for example,
36 | instead of opening dictionary apps directly, you can have the overlay buttons
37 | copy to the clipboard or send them to another app via the share menu.
38 |
39 | ## Installation
40 |
41 | Textbender can be downloaded from the [Releases
42 | page](https://github.com/elizagamedev/android-textbender/releases). It is also
43 | unofficially hosted on the IzzyOnDroid repo
44 | [here](https://apt.izzysoft.de/fdroid/index/apk/sh.eliza.textbender). I plan on
45 | adding it to F-Droid when I can muster the energy.
46 |
47 | ## Usage Details
48 |
49 | ### Overlay and Clipboard
50 |
51 | The text button overlay and clipboard are two features that require user
52 | interaction to start.
53 |
54 | #### Accessibility Shortcut
55 |
56 | If you activate the accessibility shortcut, Textbender will open the button
57 | overlay. This shortcut can take the form of a floating button or a gesture. See
58 | the [official Android
59 | documentation](https://support.google.com/accessibility/android/answer/7650693)
60 | for more information.
61 |
62 | #### Floating Buttons
63 |
64 | You can configure Textbender to use a floating set of buttons, configurable to
65 | show a button for the button overlay, clipboard, or both. Note that this is
66 | different from the accessibility shortcut button described above.
67 |
68 | #### Quick Settings Tiles
69 |
70 | Textbender adds some [quick settings
71 | tiles](https://support.google.com/android/answer/9083864) to activate the text
72 | button overlay, bend the clipboard text, or toggle the floating buttons
73 | described above.
74 |
75 | #### Fingerprint gestures
76 |
77 | Some devices (e.g. Google Pixel 5) have the ability to use gestures across the
78 | fingerprint sensor as shortcuts. If you have a supported device, you can
79 | configure Textbender to either open the button overlay or bend the clipboard
80 | depending on which direction you swipe.
81 |
82 | ### Sources
83 |
84 | #### URL scheme
85 |
86 | Textbender accepts URLs with the format `textbender://x?x={text}`, where
87 | `{text}` is the text to bend. You can edit your Anki card template to add a link
88 | to your note. For example, to add a link to open a note field named
89 | “Expression”…
90 |
91 | ``` html
92 | Open in Yomichan
93 | ```
94 |
95 | ### Destinations
96 |
97 | #### Open in URL
98 |
99 | The URL format will replace any instances of the string `{text}` with the text
100 | to be bent. For example, the URL format `https://duckduckgo.com/q={text}`, when
101 | bent with the source text `foo`, will open the URL
102 | `https://duckduckgo.com/q=foo`.
103 |
104 | #### Yomichan
105 |
106 | The Yomichan integration works by automating the process of opening the search
107 | page in Yomichan with the given text, since it is not possible for an external
108 | app to otherwise directly open a browser page to an addon. It has the following
109 | requirements:
110 |
111 | - Textbender’s accessibility service is enabled.
112 | - Kiwi Browser and the Yomichan extension are both installed.
113 | - Kiwi Browser has at least one tab open, i.e. the address bar is visible.
114 |
115 | ## Development
116 |
117 | The Textbender dev environment is provided as a Nix flake and is very easy to
118 | build with [Nix](https://nixos.org/).
119 |
120 | ``` shell
121 | nix develop
122 | ./gradlew installDebug
123 | ```
124 |
125 | It’s really that simple. (Assuming you have Nix set up correctly.)
126 |
--------------------------------------------------------------------------------
/app/src/main/java/sh/eliza/textbender/FloatingButtons.kt:
--------------------------------------------------------------------------------
1 | package sh.eliza.textbender
2 |
3 | import android.content.Intent
4 | import android.graphics.PixelFormat
5 | import android.util.TypedValue
6 | import android.view.ContextThemeWrapper
7 | import android.view.LayoutInflater
8 | import android.view.MotionEvent
9 | import android.view.View
10 | import android.view.WindowManager
11 | import android.widget.Button
12 | import kotlin.math.abs
13 |
14 | private const val DRAG_DIAMETER_DP = 16f
15 |
16 | class FloatingButtons(private val service: TextbenderService) : AutoCloseable {
17 | private val preferences = TextbenderPreferences.getInstance(service.applicationContext)
18 |
19 | private val root: View = run {
20 | val layoutInflater =
21 | LayoutInflater.from(ContextThemeWrapper(service, R.style.Theme_Textbender_Light))
22 | @Suppress("InflateParams") layoutInflater.inflate(R.layout.floating_buttons, /*root=*/ null)
23 | }
24 |
25 | private val overlayButton = root.findViewById