├── .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 | 20 | 40 | 43 | 44 | 46 | 52 | 55 | 59 | 63 | 64 | 73 | 81 | 85 | 86 | 87 | 91 | 98 | 99 | 104 | 109 | 114 | 115 | 116 | 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 | ![](images/demo-overlay.gif) 19 | 20 | You can also access text from other sources; another commonly used one might be 21 | via the context menu. 22 | 23 | ![](images/demo-context.gif) 24 | 25 | Some apps don’t play nice with Android and only allow you to copy text. 26 | Textbender has that covered: 27 | 28 | ![](images/demo-clipboard.gif) 29 | 30 | You can also do things like integrate Textbender with your Anki notes to open a 31 | word in Yomichan. 32 | 33 | ![](images/demo-url.gif) 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