├── app
├── .gitignore
├── src
│ ├── main
│ │ ├── ic_launcher-playstore.png
│ │ ├── res
│ │ │ ├── xml
│ │ │ │ ├── file_paths.xml
│ │ │ │ ├── locales_config.xml
│ │ │ │ ├── backup_rules.xml
│ │ │ │ ├── data_extraction_rules.xml
│ │ │ │ └── shortcuts.xml
│ │ │ ├── values
│ │ │ │ ├── ic_launcher_background.xml
│ │ │ │ ├── themes.xml
│ │ │ │ ├── colors.xml
│ │ │ │ └── array.xml
│ │ │ ├── mipmap-anydpi-v26
│ │ │ │ └── ic_launcher.xml
│ │ │ └── drawable
│ │ │ │ └── ic_launcher_foreground.xml
│ │ ├── java
│ │ │ ├── dev
│ │ │ │ └── fabik
│ │ │ │ │ └── bluetoothhid
│ │ │ │ │ ├── ui
│ │ │ │ │ ├── theme
│ │ │ │ │ │ ├── Color.kt
│ │ │ │ │ │ ├── Type.kt
│ │ │ │ │ │ └── Theme.kt
│ │ │ │ │ ├── model
│ │ │ │ │ │ ├── DevicesViewModel.kt
│ │ │ │ │ │ └── HistoryViewModel.kt
│ │ │ │ │ ├── Tooltip.kt
│ │ │ │ │ ├── Navigation.kt
│ │ │ │ │ ├── Permissions.kt
│ │ │ │ │ ├── Preference.kt
│ │ │ │ │ ├── JsEditor.kt
│ │ │ │ │ ├── SaveScanImageOptions.kt
│ │ │ │ │ ├── AdvancedScannerOptions.kt
│ │ │ │ │ ├── HistoryFilter.kt
│ │ │ │ │ └── Dropdown.kt
│ │ │ │ │ ├── utils
│ │ │ │ │ ├── Lifecycle.kt
│ │ │ │ │ ├── SystemBroadcastReceiver.kt
│ │ │ │ │ ├── LatencyTrace.kt
│ │ │ │ │ ├── DeviceInfo.kt
│ │ │ │ │ ├── ImageUtils.kt
│ │ │ │ │ ├── ZXingAnalyzer.kt
│ │ │ │ │ └── JsEngineService.kt
│ │ │ │ │ ├── bt
│ │ │ │ │ ├── KeyboardSender.kt
│ │ │ │ │ ├── Descriptor.kt
│ │ │ │ │ └── BluetoothService.kt
│ │ │ │ │ ├── MainActivity.kt
│ │ │ │ │ └── SettingsActivity.kt
│ │ │ └── org
│ │ │ │ └── totschnig
│ │ │ │ └── ocr
│ │ │ │ └── Text.kt
│ │ ├── assets
│ │ │ └── keymaps
│ │ │ │ ├── us.layout
│ │ │ │ ├── it.layout
│ │ │ │ ├── es.layout
│ │ │ │ ├── en.layout
│ │ │ │ ├── de.layout
│ │ │ │ ├── tr.layout
│ │ │ │ ├── base.layout
│ │ │ │ ├── fr.layout
│ │ │ │ ├── cz.layout
│ │ │ │ └── pl.layout
│ │ └── AndroidManifest.xml
│ ├── test
│ │ └── java
│ │ │ └── dev
│ │ │ └── fabik
│ │ │ └── bluetoothhid
│ │ │ ├── ExampleUnitTest.kt
│ │ │ └── utils
│ │ │ ├── DeviceInfoTest.kt
│ │ │ ├── TemplateProcessorTest.kt
│ │ │ └── SerializerTest.kt
│ └── androidTest
│ │ └── java
│ │ └── dev
│ │ └── fabik
│ │ └── bluetoothhid
│ │ └── ExampleInstrumentedTest.kt
├── proguard-rules.pro
└── build.gradle
├── img
├── main.png
├── devices.png
├── settings1.png
└── settings2.png
├── .idea
├── .gitignore
├── codeStyles
│ ├── codeStyleConfig.xml
│ └── Project.xml
├── compiler.xml
├── kotlinc.xml
├── vcs.xml
├── AndroidProjectSystem.xml
├── markdown.xml
├── deploymentTargetSelector.xml
├── misc.xml
├── gradle.xml
├── runConfigurations.xml
├── appInsightsSettings.xml
└── inspectionProfiles
│ └── Project_Default.xml
├── gradle
└── wrapper
│ ├── gradle-wrapper.jar
│ └── gradle-wrapper.properties
├── .gitignore
├── settings.gradle
├── .github
├── dependabot.yml
└── workflows
│ ├── stale.yml
│ ├── test.yml
│ └── android.yml
├── gradle.properties
├── gradlew.bat
├── PRIVACY.md
└── gradlew
/app/.gitignore:
--------------------------------------------------------------------------------
1 | /build
--------------------------------------------------------------------------------
/img/main.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Fabi019/hid-barcode-scanner/HEAD/img/main.png
--------------------------------------------------------------------------------
/.idea/.gitignore:
--------------------------------------------------------------------------------
1 | # Default ignored files
2 | /shelf/
3 | /workspace.xml
4 | /sonarlint/
5 |
--------------------------------------------------------------------------------
/img/devices.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Fabi019/hid-barcode-scanner/HEAD/img/devices.png
--------------------------------------------------------------------------------
/img/settings1.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Fabi019/hid-barcode-scanner/HEAD/img/settings1.png
--------------------------------------------------------------------------------
/img/settings2.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Fabi019/hid-barcode-scanner/HEAD/img/settings2.png
--------------------------------------------------------------------------------
/gradle/wrapper/gradle-wrapper.jar:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Fabi019/hid-barcode-scanner/HEAD/gradle/wrapper/gradle-wrapper.jar
--------------------------------------------------------------------------------
/app/src/main/ic_launcher-playstore.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Fabi019/hid-barcode-scanner/HEAD/app/src/main/ic_launcher-playstore.png
--------------------------------------------------------------------------------
/app/src/main/res/xml/file_paths.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
6 |
--------------------------------------------------------------------------------
/app/src/main/res/values/ic_launcher_background.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 | #2196F3
4 |
--------------------------------------------------------------------------------
/.idea/codeStyles/codeStyleConfig.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
--------------------------------------------------------------------------------
/app/src/main/res/values/themes.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
--------------------------------------------------------------------------------
/.idea/compiler.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/.idea/kotlinc.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/.idea/vcs.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/.idea/AndroidProjectSystem.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/app/src/main/res/xml/locales_config.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
--------------------------------------------------------------------------------
/gradle/wrapper/gradle-wrapper.properties:
--------------------------------------------------------------------------------
1 | #Fri Nov 01 16:18:04 CET 2024
2 | distributionBase=GRADLE_USER_HOME
3 | distributionPath=wrapper/dists
4 | distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-bin.zip
5 | zipStoreBase=GRADLE_USER_HOME
6 | zipStorePath=wrapper/dists
7 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | *.iml
2 | .gradle
3 | /local.properties
4 | /.idea/caches
5 | /.idea/libraries
6 | /.idea/modules.xml
7 | /.idea/workspace.xml
8 | /.idea/navEditor.xml
9 | /.idea/assetWizardSettings.xml
10 | .DS_Store
11 | /build
12 | /captures
13 | .externalNativeBuild
14 | .cxx
15 | local.properties
16 |
--------------------------------------------------------------------------------
/.idea/markdown.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
--------------------------------------------------------------------------------
/.idea/deploymentTargetSelector.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/app/src/main/java/dev/fabik/bluetoothhid/ui/theme/Color.kt:
--------------------------------------------------------------------------------
1 | package dev.fabik.bluetoothhid.ui.theme
2 |
3 | import androidx.compose.ui.graphics.Color
4 |
5 | val Purple80 = Color(0xFFD0BCFF)
6 | val PurpleGrey80 = Color(0xFFCCC2DC)
7 | val Pink80 = Color(0xFFEFB8C8)
8 |
9 | val Purple40 = Color(0xFF6650a4)
10 | val PurpleGrey40 = Color(0xFF625b71)
11 | val Pink40 = Color(0xFF7D5260)
12 |
13 | val Neutral95 = Color(244, 239, 244)
--------------------------------------------------------------------------------
/.idea/misc.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
--------------------------------------------------------------------------------
/app/src/main/res/values/colors.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 | #FFBB86FC
4 | #FF6200EE
5 | #FF3700B3
6 | #FF03DAC5
7 | #FF018786
8 | #FF000000
9 | #FFFFFFFF
10 |
--------------------------------------------------------------------------------
/app/src/test/java/dev/fabik/bluetoothhid/ExampleUnitTest.kt:
--------------------------------------------------------------------------------
1 | package dev.fabik.bluetoothhid
2 |
3 | import org.junit.Assert.assertEquals
4 | import org.junit.Test
5 |
6 | /**
7 | * Example local unit test, which will execute on the development machine (host).
8 | *
9 | * See [testing documentation](http://d.android.com/tools/testing).
10 | */
11 | class ExampleUnitTest {
12 | @Test
13 | fun addition_isCorrect() {
14 | assertEquals(4, 2 + 2)
15 | }
16 | }
--------------------------------------------------------------------------------
/app/src/main/assets/keymaps/us.layout:
--------------------------------------------------------------------------------
1 | ## Keymap for a standard US keyboard layout
2 |
3 | - 2D 00
4 | = 2E 00
5 | [ 2F 00
6 | ] 30 00
7 | \ 31 00
8 | ; 33 00
9 | ' 34 00
10 | ` 35 00
11 | , 36 00
12 | . 37 00
13 | / 38 00
14 |
15 | ! 1E 02
16 | @ 1F 02
17 | # 20 02
18 | $ 21 02
19 | % 22 02
20 | ^ 23 02
21 | & 24 02
22 | * 25 02
23 | ( 26 02
24 | ) 27 02
25 | _ 2D 02
26 | + 2E 02
27 | { 2F 02
28 | } 30 02
29 | | 31 02
30 | : 33 02
31 | " 34 02
32 | ~ 35 02
33 | < 36 02
34 | > 37 02
35 | ? 38 02
--------------------------------------------------------------------------------
/settings.gradle:
--------------------------------------------------------------------------------
1 | pluginManagement {
2 | repositories {
3 | gradlePluginPortal()
4 | google()
5 | mavenCentral()
6 | }
7 | }
8 | // Use old style dependency declaration since the new one is not yet supported by dependabot
9 | /*dependencyResolutionManagement {
10 | repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
11 | repositories {
12 | google()
13 | mavenCentral()
14 | }
15 | }*/
16 | rootProject.name = "BluetoothHID"
17 | include ':app'
18 |
--------------------------------------------------------------------------------
/.github/dependabot.yml:
--------------------------------------------------------------------------------
1 | # To get started with Dependabot version updates, you'll need to specify which
2 | # package ecosystems to update and where the package manifests are located.
3 | # Please see the documentation for all configuration options:
4 | # https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates
5 |
6 | version: 2
7 | updates:
8 | - package-ecosystem: "gradle"
9 | directory: "/"
10 | schedule:
11 | interval: "daily"
12 | open-pull-requests-limit: 10
13 |
--------------------------------------------------------------------------------
/app/src/main/res/xml/backup_rules.xml:
--------------------------------------------------------------------------------
1 |
8 |
9 |
13 |
--------------------------------------------------------------------------------
/app/src/main/assets/keymaps/it.layout:
--------------------------------------------------------------------------------
1 | ## Keymap overlay for a Italian keyboard layout
2 |
3 | \ 35 00
4 | ' 2D 00
5 | ì 2E 00
6 | è 2F 00
7 | + 30 00
8 | ò 33 00
9 | à 34 00
10 | ù 32 00
11 | - 38 00
12 | . 37 00
13 | , 36 00
14 | < 64 00
15 |
16 | | 35 02
17 | ! 1E 02
18 | " 1F 02
19 | £ 20 02
20 | $ 21 02
21 | % 22 02
22 | & 23 02
23 | / 24 02
24 | ( 25 02
25 | ) 26 02
26 | = 27 02
27 | ? 2D 02
28 | ^ 2E 02
29 | é 2F 02
30 | * 30 02
31 | ç 33 02
32 | º 34 02
33 | § 32 02
34 | _ 38 02
35 | : 37 02
36 | ; 36 02
37 | > 64 02
38 |
39 | @ 33 40
40 | # 34 40
41 | [ 2F 40
42 | ] 30 40
43 | € 08 40
44 |
45 | { 2F 42
46 | } 30 42
--------------------------------------------------------------------------------
/app/src/main/assets/keymaps/es.layout:
--------------------------------------------------------------------------------
1 | ## Keymap overlay for a Spanish keyboard
2 |
3 | º 35 00
4 | ' 2D 00
5 | ¡ 2E 00
6 | ` 2F 00
7 | + 30 00
8 | ñ 33 00
9 | ç 31 00
10 | ´ 34 00
11 | < 64 00
12 | , 36 00
13 | . 37 00
14 | - 38 00
15 |
16 | Ç 31 02
17 | Ñ 33 02
18 | ª 35 02
19 | ^ 2F 02
20 | * 30 02
21 | > 64 02
22 | ! 1E 02
23 | " 1F 02
24 | · 20 02
25 | $ 21 02
26 | % 22 02
27 | & 23 02
28 | / 24 02
29 | ( 25 02
30 | ) 26 02
31 | = 27 02
32 | ? 2D 02
33 | ¿ 2E 02
34 | ; 36 02
35 | : 37 02
36 | _ 38 02
37 |
38 | \ 35 40
39 | | 1E 40
40 | @ 1F 40
41 | # 20 40
42 | ~ 21 40
43 | € 22 40
44 | ¬ 23 40
45 | [ 2F 40
46 | ] 30 40
47 | { 34 40
48 | } 31 40
--------------------------------------------------------------------------------
/app/src/main/java/org/totschnig/ocr/Text.kt:
--------------------------------------------------------------------------------
1 | package org.totschnig.ocr
2 |
3 | import android.graphics.Rect
4 | import android.os.Parcelable
5 | import androidx.annotation.Keep
6 | import kotlinx.parcelize.Parcelize
7 |
8 | @Parcelize
9 | @Keep
10 | data class Text(val textBlocks: List) : Parcelable
11 |
12 | @Parcelize
13 | @Keep
14 | data class TextBlock(val lines: List) : Parcelable
15 |
16 | @Parcelize
17 | @Keep
18 | data class Line(val text: String, val boundingBox: Rect?, val elements: List) : Parcelable
19 |
20 | @Parcelize
21 | @Keep
22 | data class Element(val text: String, val boundingBox: Rect?) : Parcelable
--------------------------------------------------------------------------------
/app/src/main/res/xml/data_extraction_rules.xml:
--------------------------------------------------------------------------------
1 |
6 |
7 |
8 |
12 |
13 |
19 |
--------------------------------------------------------------------------------
/app/src/main/assets/keymaps/en.layout:
--------------------------------------------------------------------------------
1 | ## Keymap overlay for a English UK keyboard
2 |
3 | - 2D 00
4 | = 2E 00
5 | [ 2F 00
6 | ] 30 00
7 | \ 31 00
8 | ; 33 00
9 | ' 34 00
10 | ` 35 00
11 | , 36 00
12 | . 37 00
13 | / 38 00
14 |
15 | ! 1E 02
16 | # 20 02
17 | % 22 02
18 | ^ 23 02
19 | & 24 02
20 | * 25 02
21 | ( 26 02
22 | ) 27 02
23 | _ 2D 02
24 | + 2E 02
25 | { 2F 02
26 | } 30 02
27 | | 31 02
28 | : 33 02
29 | " 34 02
30 | ~ 35 02
31 | < 36 02
32 | > 37 02
33 | ? 38 02
34 |
35 | \ 64 00
36 |
37 | " 1F 02
38 | £ 20 02
39 | @ 34 02
40 | | 64 02
41 |
42 | € 21 40
43 |
44 | à 04 40
45 | è 08 40
46 | ù 18 40
47 | ì 0C 40
48 | ò 12 40
49 |
50 | À 04 42
51 | È 08 42
52 | Ù 18 42
53 | Ì 0C 42
54 | Ò 12 42
--------------------------------------------------------------------------------
/app/src/main/assets/keymaps/de.layout:
--------------------------------------------------------------------------------
1 | ## Keymap overlay for a German keyboard layout
2 |
3 | ß 2D 00
4 | ´ 2E 00
5 | ü 2F 00
6 | + 30 00
7 | # 31 00
8 | ö 33 00
9 | ä 34 00
10 | ^ 35 00
11 | , 36 00
12 | . 37 00
13 | - 38 00
14 | z 1C 00
15 | y 1D 00
16 | < 64 00
17 |
18 | ! 1E 02
19 | " 1F 02
20 | § 20 02
21 | $ 21 02
22 | % 22 02
23 | & 23 02
24 | / 24 02
25 | ( 25 02
26 | ) 26 02
27 | = 27 02
28 | ? 2D 02
29 | ` 2E 02
30 | Ü 2F 02
31 | * 30 02
32 | ' 31 02
33 | Ö 33 02
34 | Ä 34 02
35 | ° 35 02
36 | ; 36 02
37 | : 37 02
38 | _ 38 02
39 | Z 1C 02
40 | Y 1D 02
41 | > 64 02
42 |
43 | { 24 40
44 | [ 25 40
45 | ] 26 40
46 | } 27 40
47 | \ 2D 40
48 | ~ 30 40
49 | µ 10 40
50 | @ 14 40
51 | € 08 40
52 | | 64 40
--------------------------------------------------------------------------------
/app/src/main/assets/keymaps/tr.layout:
--------------------------------------------------------------------------------
1 | ## Keymap overlay for a Turkish Q keyboard layout
2 |
3 | " 35 00
4 | * 2D 00
5 | - 2E 00
6 | ğ 2F 00
7 | ü 30 00
8 | , 31 00
9 | ş 33 00
10 | i 34 00
11 | ı 0C 00
12 | ö 36 00
13 | ç 37 00
14 | . 38 00
15 |
16 | é 35 02
17 | ! 1E 02
18 | ' 1F 02
19 | ^ 20 02
20 | + 21 02
21 | % 22 02
22 | & 23 02
23 | / 24 02
24 | ( 25 02
25 | ) 26 02
26 | = 27 02
27 | ? 2D 02
28 | _ 2E 02
29 | ; 31 02
30 | Ü 30 02
31 | Ğ 2F 02
32 | Ş 33 02
33 | İ 34 02
34 | : 38 02
35 | Ç 37 02
36 | Ö 36 02
37 |
38 | < 35 40
39 | > 1E 40
40 | £ 1F 40
41 | # 20 40
42 | $ 21 40
43 | ½ 22 40
44 | { 24 40
45 | [ 25 40
46 | ] 26 40
47 | } 27 40
48 | \ 2D 40
49 | | 2E 40
50 | @ 14 40
51 | € 08 40
52 | ₺ 17 40
53 | ß 16 40
54 |
--------------------------------------------------------------------------------
/.idea/gradle.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
18 |
19 |
--------------------------------------------------------------------------------
/app/src/androidTest/java/dev/fabik/bluetoothhid/ExampleInstrumentedTest.kt:
--------------------------------------------------------------------------------
1 | package dev.fabik.bluetoothhid
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("dev.fabik.bluetoothhid", appContext.packageName)
23 | }
24 | }
--------------------------------------------------------------------------------
/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.
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
--------------------------------------------------------------------------------
/app/src/main/assets/keymaps/base.layout:
--------------------------------------------------------------------------------
1 | ## Base QWERTY Layout
2 |
3 | a 04 00
4 | b 05 00
5 | c 06 00
6 | d 07 00
7 | e 08 00
8 | f 09 00
9 | g 0A 00
10 | h 0B 00
11 | i 0C 00
12 | j 0D 00
13 | k 0E 00
14 | l 0F 00
15 | m 10 00
16 | n 11 00
17 | o 12 00
18 | p 13 00
19 | q 14 00
20 | r 15 00
21 | s 16 00
22 | t 17 00
23 | u 18 00
24 | v 19 00
25 | w 1A 00
26 | x 1B 00
27 | y 1C 00
28 | z 1D 00
29 |
30 | A 04 02
31 | B 05 02
32 | C 06 02
33 | D 07 02
34 | E 08 02
35 | F 09 02
36 | G 0A 02
37 | H 0B 02
38 | I 0C 02
39 | J 0D 02
40 | K 0E 02
41 | L 0F 02
42 | M 10 02
43 | N 11 02
44 | O 12 02
45 | P 13 02
46 | Q 14 02
47 | R 15 02
48 | S 16 02
49 | T 17 02
50 | U 18 02
51 | V 19 02
52 | W 1A 02
53 | X 1B 02
54 | Y 1C 02
55 | Z 1D 02
56 |
57 | 1 1E 00
58 | 2 1F 00
59 | 3 20 00
60 | 4 21 00
61 | 5 22 00
62 | 6 23 00
63 | 7 24 00
64 | 8 25 00
65 | 9 26 00
66 | 0 27 00
--------------------------------------------------------------------------------
/app/src/main/res/values/array.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | - CODE_128
5 | - CODE_39
6 | - CODE_93
7 | - CODABAR
8 | - DATA_MATRIX
9 | - EAN_13
10 | - EAN_8
11 | - ITF
12 | - QR_CODE
13 | - UPC_A
14 | - UPC_E
15 | - PDF417
16 | - AZTEC
17 | - DATA_BAR
18 | - DATA_BAR_EXPANDED
19 | - DATA_BAR_LIMITED
20 | - DX_FILM_EDGE
21 | - MAXICODE
22 | - MICRO_QR_CODE
23 | - RMQR_CODE
24 |
25 |
--------------------------------------------------------------------------------
/app/src/main/assets/keymaps/fr.layout:
--------------------------------------------------------------------------------
1 | ## Keymap overlay for a French keyboard layout
2 |
3 | a 14 00
4 | z 1A 00
5 | q 04 00
6 | m 33 00
7 | w 1D 00
8 |
9 | ² 35 00
10 | & 1E 00
11 | é 1F 00
12 | " 20 00
13 | ' 21 00
14 | ( 22 00
15 | - 23 00
16 | è 24 00
17 | _ 25 00
18 | ç 26 00
19 | à 27 00
20 | ) 2D 00
21 | = 2E 00
22 | * 31 00
23 | $ 30 00
24 | , 10 00
25 | ; 36 00
26 | : 37 00
27 | ! 38 00
28 | ù 34 00
29 | * 32 00
30 | < 64 00
31 |
32 | A 14 02
33 | Z 1A 02
34 | Q 04 02
35 | M 33 02
36 | W 1D 02
37 |
38 | 1 1E 02
39 | 2 1F 02
40 | 3 20 02
41 | 4 21 02
42 | 5 22 02
43 | 6 23 02
44 | 7 24 02
45 | 8 25 02
46 | 9 26 02
47 | 0 27 02
48 | ° 2D 02
49 | + 2E 02
50 | £ 30 02
51 | µ 32 02
52 | % 34 02
53 | § 38 02
54 | / 37 02
55 | . 36 02
56 | ? 10 02
57 | > 64 02
58 |
59 | ~ 1F 40
60 | # 20 40
61 | { 21 40
62 | [ 22 40
63 | | 23 40
64 | ` 24 40
65 | \ 25 40
66 | ^ 26 40
67 | @ 27 40
68 | ] 2D 40
69 | } 2E 40
70 | ¤ 30 40
71 | € 08 40
72 |
--------------------------------------------------------------------------------
/.github/workflows/stale.yml:
--------------------------------------------------------------------------------
1 | name: 'Close stale issues and PR'
2 |
3 | permissions:
4 | issues: write
5 | pull-requests: write
6 |
7 | on:
8 | schedule:
9 | - cron: '30 1 * * *'
10 |
11 | jobs:
12 | stale:
13 | runs-on: ubuntu-latest
14 | steps:
15 | - uses: actions/stale@v8
16 | with:
17 | exempt-issue-labels: 'work-in-progress,enhancement,bug'
18 | exempt-pr-labels: 'work-in-progress,dependencies'
19 | stale-issue-message: 'This issue is stale because it has been open 30 days with no activity. Remove stale label or comment or this will be closed in 5 days.'
20 | stale-pr-message: 'This PR is stale because it has been open 45 days with no activity.'
21 | close-issue-message: 'This issue was closed because it has been stalled for 7 days with no activity.'
22 | days-before-stale: 30
23 | days-before-close: 7
24 | days-before-pr-close: -1
25 |
--------------------------------------------------------------------------------
/app/src/main/assets/keymaps/cz.layout:
--------------------------------------------------------------------------------
1 | ## Keymap for a czech qwertz layout
2 | ## Original author: Jaunawab
3 |
4 | z 1C 00
5 | y 1D 00
6 |
7 | Z 1C 02
8 | Y 1D 02
9 |
10 | 1 1E 02
11 | 2 1F 02
12 | 3 20 02
13 | 4 21 02
14 | 5 22 02
15 | 6 23 02
16 | 7 24 02
17 | 8 25 02
18 | 9 26 02
19 | 0 27 02
20 |
21 | + 1E 00
22 | ě 1F 00
23 | š 20 00
24 | č 21 00
25 | ř 22 00
26 | ž 23 00
27 | ý 24 00
28 | á 25 00
29 | í 26 00
30 | é 27 00
31 |
32 | ; 35 00
33 | = 2D 00
34 | ú 2F 00
35 | ) 30 00
36 | ů 33 00
37 | § 34 00
38 | , 36 00
39 | . 37 00
40 | - 38 00
41 |
42 |
43 | % 2D 00
44 | / 2F 02
45 | ( 30 02
46 | ' 31 02
47 | " 33 02
48 | ! 34 02
49 | ? 36 02
50 | : 37 02
51 | _ 38 02
52 |
53 |
54 | # 1B 05
55 | & 06 05
56 | @ 19 05
57 | { 05 05
58 | } 11 05
59 | < 36 05
60 | > 37 05
61 | * 38 05
62 | đ 16 05
63 | Đ 07 05
64 | [ 09 05
65 | ] 0A 05
66 | ł 0E 05
67 | Ł 0F 05
68 | $ 33 05
69 | ß 34 05
70 | ¤ 31 05
71 | \ 14 05
72 | | 1A 05
73 | € 08 05
74 | ÷ 2F 05
75 | × 30 05
76 | ~ 1E 05
77 |
--------------------------------------------------------------------------------
/.github/workflows/test.yml:
--------------------------------------------------------------------------------
1 | name: Test
2 |
3 | on:
4 | push:
5 | branches: [ "main" ]
6 | paths:
7 | - 'app/**'
8 | - 'build.gradle'
9 | pull_request:
10 | paths:
11 | - 'app/**'
12 | - 'build.gradle'
13 |
14 | jobs:
15 | build:
16 |
17 | runs-on: ubuntu-latest
18 |
19 | steps:
20 | - uses: actions/checkout@v3
21 |
22 | - name: Set up JDK 17
23 | uses: actions/setup-java@v3
24 | with:
25 | java-version: '17'
26 | distribution: 'temurin'
27 | cache: gradle
28 |
29 | - name: Grant execute permission for gradlew
30 | run: chmod +x gradlew
31 |
32 | - name: Run tests
33 | run: ./gradlew test
34 |
35 | - name: Build with Gradle
36 | run: ./gradlew assembleDebug
37 |
38 | - name: Upload Debug APK
39 | uses: actions/upload-artifact@v4
40 | with:
41 | name: APK(s) debug generated
42 | path: ./app/build/outputs/apk/debug/
43 |
--------------------------------------------------------------------------------
/app/src/main/java/dev/fabik/bluetoothhid/utils/Lifecycle.kt:
--------------------------------------------------------------------------------
1 | package dev.fabik.bluetoothhid.utils
2 |
3 | import androidx.compose.runtime.Composable
4 | import androidx.compose.runtime.DisposableEffect
5 | import androidx.lifecycle.Lifecycle
6 | import androidx.lifecycle.LifecycleEventObserver
7 | import androidx.lifecycle.LifecycleOwner
8 | import androidx.lifecycle.compose.LocalLifecycleOwner
9 |
10 | // adapted from: https://medium.com/@mahdizareeii/handling-app-lifecycle-events-in-jetpack-compose-d3b7f526514b
11 | @Composable
12 | fun ComposableLifecycle(
13 | lifeCycleOwner: LifecycleOwner = LocalLifecycleOwner.current,
14 | onEvent: (LifecycleOwner, Lifecycle.Event) -> Unit
15 | ) {
16 | DisposableEffect(lifeCycleOwner) {
17 | val observer = LifecycleEventObserver { source, event ->
18 | onEvent(source, event)
19 | }
20 | lifeCycleOwner.lifecycle.addObserver(observer)
21 | onDispose {
22 | lifeCycleOwner.lifecycle.removeObserver(observer)
23 | }
24 | }
25 | }
--------------------------------------------------------------------------------
/.idea/runConfigurations.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
--------------------------------------------------------------------------------
/.idea/appInsightsSettings.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
24 |
25 |
26 |
--------------------------------------------------------------------------------
/app/src/main/java/dev/fabik/bluetoothhid/ui/theme/Type.kt:
--------------------------------------------------------------------------------
1 | package dev.fabik.bluetoothhid.ui.theme
2 |
3 | import androidx.compose.material3.Typography
4 | import androidx.compose.ui.text.TextStyle
5 | import androidx.compose.ui.text.font.FontFamily
6 | import androidx.compose.ui.text.font.FontWeight
7 | import androidx.compose.ui.unit.sp
8 |
9 | // Set of Material typography styles to start with
10 | val Typography = Typography(
11 | bodyLarge = TextStyle(
12 | fontFamily = FontFamily.Default,
13 | fontWeight = FontWeight.Normal,
14 | fontSize = 16.sp,
15 | lineHeight = 24.sp,
16 | letterSpacing = 0.5.sp
17 | )
18 | /* Other default text styles to override
19 | titleLarge = TextStyle(
20 | fontFamily = FontFamily.Default,
21 | fontWeight = FontWeight.Normal,
22 | fontSize = 22.sp,
23 | lineHeight = 28.sp,
24 | letterSpacing = 0.sp
25 | ),
26 | labelSmall = TextStyle(
27 | fontFamily = FontFamily.Default,
28 | fontWeight = FontWeight.Medium,
29 | fontSize = 11.sp,
30 | lineHeight = 16.sp,
31 | letterSpacing = 0.5.sp
32 | )
33 | */
34 | )
--------------------------------------------------------------------------------
/app/src/main/java/dev/fabik/bluetoothhid/ui/model/DevicesViewModel.kt:
--------------------------------------------------------------------------------
1 | package dev.fabik.bluetoothhid.ui.model
2 |
3 | import android.bluetooth.BluetoothDevice
4 | import androidx.compose.runtime.getValue
5 | import androidx.compose.runtime.mutableStateListOf
6 | import androidx.compose.runtime.mutableStateOf
7 | import androidx.compose.runtime.setValue
8 | import androidx.lifecycle.ViewModel
9 | import androidx.lifecycle.viewModelScope
10 | import dev.fabik.bluetoothhid.bt.BluetoothController
11 | import kotlinx.coroutines.delay
12 | import kotlinx.coroutines.launch
13 |
14 | class DevicesViewModel : ViewModel() {
15 | var foundDevices = mutableStateListOf()
16 | var pairedDevices = mutableStateListOf()
17 |
18 | var isScanning by mutableStateOf(false)
19 | var isRefreshing by mutableStateOf(false)
20 |
21 | // Initially assume it is enabled to prevent the card from wrongly showing up
22 | var isBluetoothEnabled by mutableStateOf(true)
23 |
24 | fun refresh(controller: BluetoothController?) {
25 | viewModelScope.launch {
26 | isRefreshing = true
27 | pairedDevices.clear()
28 | pairedDevices.addAll(controller?.pairedDevices ?: emptyList())
29 | if (!isScanning) {
30 | controller?.scanDevices()
31 | }
32 | delay(500)
33 | isRefreshing = false
34 | }
35 | }
36 | }
37 |
--------------------------------------------------------------------------------
/app/src/test/java/dev/fabik/bluetoothhid/utils/DeviceInfoTest.kt:
--------------------------------------------------------------------------------
1 | package dev.fabik.bluetoothhid.utils
2 |
3 | import android.bluetooth.BluetoothClass
4 | import org.junit.Assert.assertEquals
5 | import org.junit.Test
6 | import org.mockito.kotlin.mock
7 | import org.mockito.kotlin.whenever
8 |
9 | class DeviceInfoTest {
10 |
11 | @Test
12 | fun testDeviceClassString() {
13 | val computerClass = DeviceInfo.deviceClassString(BluetoothClass.Device.Major.COMPUTER)
14 | assertEquals("COMPUTER", computerClass)
15 |
16 | val uncategorizedClass =
17 | DeviceInfo.deviceClassString(BluetoothClass.Device.Major.UNCATEGORIZED)
18 | assertEquals("UNCATEGORIZED", uncategorizedClass)
19 |
20 | val unknownClass = DeviceInfo.deviceClassString(17)
21 | assertEquals("UNKNOWN", unknownClass)
22 | }
23 |
24 | @Test
25 | fun testDeviceServiceInfo() {
26 | val bluetoothClass = mock()
27 |
28 | whenever(bluetoothClass.hasService(BluetoothClass.Service.AUDIO)).thenReturn(true)
29 | whenever(bluetoothClass.hasService(BluetoothClass.Service.CAPTURE)).thenReturn(false)
30 | whenever(bluetoothClass.hasService(BluetoothClass.Service.NETWORKING)).thenReturn(true)
31 | whenever(bluetoothClass.hasService(BluetoothClass.Service.INFORMATION)).thenReturn(false)
32 | whenever(bluetoothClass.hasService(BluetoothClass.Service.LE_AUDIO)).thenReturn(true)
33 |
34 | assertEquals(
35 | listOf("AUDIO", "NETWORKING", "LE_AUDIO"),
36 | DeviceInfo.deviceServiceInfo(bluetoothClass)
37 | )
38 | }
39 |
40 | }
41 |
--------------------------------------------------------------------------------
/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=-Xmx6g -XX:+HeapDumpOnOutOfMemoryError -Dfile.encoding=UTF-8 -XX:+UseParallelGC -XX:MaxMetaspaceSize=1g
10 | org.gradle.unsafe.configuration-cache=true
11 | # When configured, Gradle will run in incubating parallel mode.
12 | # This option should only be used with decoupled projects. More details, visit
13 | # http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects
14 | # org.gradle.parallel=true
15 | # AndroidX package structure to make it clearer which packages are bundled with the
16 | # Android operating system, and which are packaged with your app's APK
17 | # https://developer.android.com/topic/libraries/support-library/androidx-rn
18 | android.useAndroidX=true
19 | android.enableJetifier=false
20 | # Kotlin code style for this project: "official" or "obsolete":
21 | kotlin.code.style=official
22 | # Enables namespacing of each library's R class so that its R class includes only the
23 | # resources declared in the library itself and none from the library's dependencies,
24 | # thereby reducing the size of the R class for that library
25 | android.nonTransitiveRClass=true
26 | android.enableR8.fullMode=true
27 | android.nonFinalResIds=false
--------------------------------------------------------------------------------
/app/src/main/java/dev/fabik/bluetoothhid/utils/SystemBroadcastReceiver.kt:
--------------------------------------------------------------------------------
1 | package dev.fabik.bluetoothhid.utils
2 |
3 | import android.content.BroadcastReceiver
4 | import android.content.Context
5 | import android.content.Intent
6 | import android.content.IntentFilter
7 | import androidx.compose.runtime.Composable
8 | import androidx.compose.runtime.DisposableEffect
9 | import androidx.compose.runtime.getValue
10 | import androidx.compose.runtime.rememberUpdatedState
11 | import androidx.compose.ui.platform.LocalContext
12 |
13 | // adapted from: https://developer.android.com/jetpack/compose/interop/interop-apis#case-study-broadcastreceivers
14 | @Composable
15 | fun SystemBroadcastReceiver(
16 | systemAction: String,
17 | onSystemEvent: (intent: Intent?) -> Unit
18 | ) {
19 | // Grab the current context in this part of the UI tree
20 | val context = LocalContext.current
21 |
22 | // Safely use the latest onSystemEvent lambda passed to the function
23 | val currentOnSystemEvent by rememberUpdatedState(onSystemEvent)
24 |
25 | // If either context or systemAction changes, unregister and register again
26 | DisposableEffect(context, systemAction) {
27 | val intentFilter = IntentFilter(systemAction)
28 | val broadcast = object : BroadcastReceiver() {
29 | override fun onReceive(context: Context?, intent: Intent?) {
30 | currentOnSystemEvent(intent)
31 | }
32 | }
33 |
34 | context.registerReceiver(broadcast, intentFilter)
35 |
36 | // When the effect leaves the Composition, remove the callback
37 | onDispose {
38 | context.unregisterReceiver(broadcast)
39 | }
40 | }
41 | }
42 |
--------------------------------------------------------------------------------
/app/src/main/res/xml/shortcuts.xml:
--------------------------------------------------------------------------------
1 |
2 |
8 |
13 |
14 |
20 |
25 |
26 |
32 |
36 |
37 |
38 |
--------------------------------------------------------------------------------
/app/src/main/java/dev/fabik/bluetoothhid/utils/LatencyTrace.kt:
--------------------------------------------------------------------------------
1 | package dev.fabik.bluetoothhid.utils
2 |
3 | import kotlinx.coroutines.flow.MutableStateFlow
4 | import kotlinx.coroutines.flow.asStateFlow
5 | import kotlinx.coroutines.flow.update
6 |
7 | class LatencyTrace(bufferSize: Int) {
8 | private var buffer = BoundedList(bufferSize)
9 |
10 | private var lastTimestamp = 0L
11 | private var lastFPSmeasurment = 0L
12 |
13 | private var currentFps = 0
14 | private var fpsCount = 0
15 |
16 | private val _currentState = MutableStateFlow(null)
17 | val state = _currentState.asStateFlow()
18 |
19 | fun trigger() {
20 | val now = System.currentTimeMillis()
21 | val latency = now - lastTimestamp
22 | lastTimestamp = now
23 |
24 | buffer.addLast(latency.toFloat())
25 |
26 | if (now - lastFPSmeasurment > 1000) {
27 | lastFPSmeasurment = now
28 | currentFps = fpsCount
29 | fpsCount = 0
30 | } else {
31 | fpsCount++
32 | }
33 |
34 | _currentState.update {
35 | State(currentFps, latency, buffer)
36 | }
37 | }
38 |
39 | data class State(val currentFps: Int, val currentLatency: Long, val history: BoundedList)
40 |
41 | inner class BoundedList(val maxSize: Int) : Iterable {
42 | var internalArray = FloatArray(maxSize) { Float.NaN }
43 | private set
44 | private var tail = 0
45 | private var head: Int? = null
46 |
47 | fun addLast(element: Float) {
48 | if (head == null) {
49 | head = tail
50 | } else if (head == tail) {
51 | head = (head!! + 1) % maxSize
52 | }
53 | internalArray[tail] = element
54 | tail = (tail + 1) % maxSize
55 | }
56 |
57 | override fun iterator() = object : Iterator {
58 | private val current = head ?: 0
59 | private var count = 0 // Keeps track of iterated elements
60 |
61 | override fun hasNext() =
62 | count < maxSize && !internalArray[(current + count) % maxSize].isNaN()
63 |
64 | override fun next(): Float = internalArray[(current + count++) % maxSize]
65 | }
66 | }
67 | }
68 |
69 |
--------------------------------------------------------------------------------
/.github/workflows/android.yml:
--------------------------------------------------------------------------------
1 | name: Release
2 |
3 | on:
4 | push:
5 | tags:
6 | - "v*.*.*"
7 |
8 | permissions:
9 | contents: write
10 |
11 | jobs:
12 | build:
13 |
14 | runs-on: ubuntu-latest
15 |
16 | steps:
17 | - uses: actions/checkout@v3
18 |
19 | - name: Set up JDK 17
20 | uses: actions/setup-java@v3
21 | with:
22 | java-version: '17'
23 | distribution: 'temurin'
24 | cache: gradle
25 |
26 | - name: Extract keystore file
27 | run: echo "${{ secrets.KEYSTORE_FILE }}" | base64 -d > $GITHUB_WORKSPACE/signing-key.jks
28 |
29 | - name: Grant execute permission for gradlew
30 | run: chmod +x gradlew
31 |
32 | - name: Build APKs with Gradle
33 | run: |
34 | ./gradlew assembleRelease \
35 | -Pandroid.injected.signing.store.file=$GITHUB_WORKSPACE/signing-key.jks \
36 | -Pandroid.injected.signing.store.password=${{ secrets.KEYSTORE_PW }} \
37 | -Pandroid.injected.signing.key.alias=${{ secrets.KEYSTORE_ALIAS }} \
38 | -Pandroid.injected.signing.key.password=${{ secrets.KEYSTORE_PW }}
39 |
40 | - name: Build AABs with Gradle
41 | run: |
42 | ./gradlew bundleRelease \
43 | -Pandroid.injected.signing.store.file=$GITHUB_WORKSPACE/signing-key.jks \
44 | -Pandroid.injected.signing.store.password=${{ secrets.KEYSTORE_PW }} \
45 | -Pandroid.injected.signing.key.alias=${{ secrets.KEYSTORE_ALIAS }} \
46 | -Pandroid.injected.signing.key.password=${{ secrets.KEYSTORE_PW }}
47 |
48 | - name: Remove keystore file
49 | run: rm $GITHUB_WORKSPACE/signing-key.jks
50 |
51 | - name: Create Release
52 | uses: softprops/action-gh-release@v1
53 | with:
54 | generate_release_notes: true
55 | draft: true
56 | files: |
57 | ./app/build/outputs/apk/release/app-arm64-v8a-release.apk
58 | ./app/build/outputs/apk/release/app-armeabi-v7a-release.apk
59 | ./app/build/outputs/apk/release/app-universal-release.apk
60 | ./app/build/outputs/apk/release/app-x86_64-release.apk
61 | ./app/build/outputs/apk/release/app-x86-release.apk
62 | ./app/build/outputs/bundle/release/app-release.aab
63 |
--------------------------------------------------------------------------------
/app/src/main/java/dev/fabik/bluetoothhid/utils/DeviceInfo.kt:
--------------------------------------------------------------------------------
1 | package dev.fabik.bluetoothhid.utils
2 |
3 | import android.bluetooth.BluetoothClass
4 |
5 | object DeviceInfo {
6 | fun deviceClassString(classMajor: Int): String = when (classMajor) {
7 | BluetoothClass.Device.Major.AUDIO_VIDEO -> "AUDIO_VIDEO"
8 | BluetoothClass.Device.Major.COMPUTER -> "COMPUTER"
9 | BluetoothClass.Device.Major.HEALTH -> "HEALTH"
10 | BluetoothClass.Device.Major.MISC -> "MISC"
11 | BluetoothClass.Device.Major.IMAGING -> "IMAGING"
12 | BluetoothClass.Device.Major.NETWORKING -> "NETWORKING"
13 | BluetoothClass.Device.Major.PERIPHERAL -> "PERIPHERAL"
14 | BluetoothClass.Device.Major.PHONE -> "PHONE"
15 | BluetoothClass.Device.Major.TOY -> "TOY"
16 | BluetoothClass.Device.Major.WEARABLE -> "WEARABLE"
17 | BluetoothClass.Device.Major.UNCATEGORIZED -> "UNCATEGORIZED"
18 | else -> "UNKNOWN"
19 | }
20 |
21 | fun deviceServiceInfo(bluetoothClass: BluetoothClass): List {
22 | val services = mutableListOf()
23 | if (bluetoothClass.hasService(BluetoothClass.Service.AUDIO))
24 | services.add("AUDIO")
25 | if (bluetoothClass.hasService(BluetoothClass.Service.CAPTURE))
26 | services.add("CAPTURE")
27 | if (bluetoothClass.hasService(BluetoothClass.Service.NETWORKING))
28 | services.add("NETWORKING")
29 | if (bluetoothClass.hasService(BluetoothClass.Service.INFORMATION))
30 | services.add("INFORMATION")
31 | if (bluetoothClass.hasService(BluetoothClass.Service.LE_AUDIO))
32 | services.add("LE_AUDIO")
33 | if (bluetoothClass.hasService(BluetoothClass.Service.LIMITED_DISCOVERABILITY))
34 | services.add("LIMITED_DISCOVERABILITY")
35 | if (bluetoothClass.hasService(BluetoothClass.Service.OBJECT_TRANSFER))
36 | services.add("OBJECT_TRANSFER")
37 | if (bluetoothClass.hasService(BluetoothClass.Service.POSITIONING))
38 | services.add("POSITIONING")
39 | if (bluetoothClass.hasService(BluetoothClass.Service.RENDER))
40 | services.add("RENDER")
41 | if (bluetoothClass.hasService(BluetoothClass.Service.TELEPHONY))
42 | services.add("TELEPHONY")
43 | return services
44 | }
45 | }
46 |
--------------------------------------------------------------------------------
/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/java/dev/fabik/bluetoothhid/bt/KeyboardSender.kt:
--------------------------------------------------------------------------------
1 | package dev.fabik.bluetoothhid.bt
2 |
3 | import android.annotation.SuppressLint
4 | import android.bluetooth.BluetoothDevice
5 | import android.bluetooth.BluetoothHidDevice
6 | import android.util.Log
7 | import dev.fabik.bluetoothhid.utils.ExtraKeys
8 | import kotlinx.coroutines.delay
9 |
10 | @SuppressLint("MissingPermission")
11 | open class KeyboardSender(
12 | private val keyboardTranslator: KeyTranslator,
13 | private val hidDevice: BluetoothHidDevice,
14 | private val host: BluetoothDevice
15 | ) {
16 | companion object {
17 | const val TAG = "KeyboardSender"
18 | }
19 |
20 | private val report = ByteArray(3) { 0 }
21 |
22 | private fun sendReport(report: ByteArray) {
23 | if (!hidDevice.sendReport(host, Descriptor.ID, report)) {
24 | Log.e(TAG, "Error sending keyboard report")
25 | }
26 | }
27 |
28 | suspend fun sendProcessedString(
29 | processedString: String,
30 | sendDelay: Long,
31 | appendKey: ExtraKeys,
32 | locale: String,
33 | expandCode: Boolean,
34 | ) {
35 | val finalString = when (appendKey) {
36 | ExtraKeys.ENTER -> "$processedString\n"
37 | ExtraKeys.TAB -> "$processedString\t"
38 | ExtraKeys.SPACE -> "$processedString "
39 | else -> processedString
40 | }
41 |
42 | val keys = when (appendKey) {
43 | ExtraKeys.CUSTOM -> {
44 | if (expandCode) {
45 | // Complex expandCode mechanism - treat processed string as template for expansion
46 | val expandedCode = keyboardTranslator.translateStringWithTemplate(finalString, locale)
47 | keyboardTranslator.translateStringWithTemplate("", locale, expandedCode)
48 | } else {
49 | // Simple template processing on the already processed string
50 | keyboardTranslator.translateStringWithTemplate(finalString, locale)
51 | }
52 | }
53 | else -> keyboardTranslator.translateString(finalString, locale)
54 | }
55 |
56 | keys.forEach { key ->
57 | Log.d(TAG, "sendProcessedString: $key")
58 | sendKey(key, sendDelay / 2)
59 | delay(sendDelay / 2)
60 | }
61 | // Send final release key (just to be sure)
62 | sendKey(0, 0)
63 | }
64 |
65 | suspend fun sendKey(key: Key, releaseDelay: Long = 10) =
66 | sendKey(key.second, key.first, releaseDelay = releaseDelay)
67 |
68 | private suspend fun sendKey(
69 | key: Byte,
70 | modifier: Byte = 0,
71 | releaseKey: Boolean = true,
72 | releaseDelay: Long = 10
73 | ) {
74 | report[0] = modifier
75 | report[2] = key
76 |
77 | sendReport(report)
78 |
79 | if (releaseKey) {
80 | report.fill(0)
81 | delay(releaseDelay)
82 | sendReport(report)
83 | }
84 | }
85 | }
86 |
--------------------------------------------------------------------------------
/app/src/main/java/dev/fabik/bluetoothhid/MainActivity.kt:
--------------------------------------------------------------------------------
1 | package dev.fabik.bluetoothhid
2 |
3 | import android.content.pm.ActivityInfo
4 | import android.os.Build
5 | import android.os.Bundle
6 | import androidx.activity.ComponentActivity
7 | import androidx.activity.compose.setContent
8 | import androidx.activity.enableEdgeToEdge
9 | import androidx.compose.foundation.layout.fillMaxSize
10 | import androidx.compose.material3.MaterialTheme
11 | import androidx.compose.material3.Surface
12 | import androidx.compose.runtime.CompositionLocalProvider
13 | import androidx.compose.runtime.LaunchedEffect
14 | import androidx.compose.runtime.getValue
15 | import androidx.compose.runtime.staticCompositionLocalOf
16 | import androidx.compose.ui.Modifier
17 | import dev.fabik.bluetoothhid.bt.BluetoothController
18 | import dev.fabik.bluetoothhid.bt.rememberBluetoothControllerService
19 | import dev.fabik.bluetoothhid.ui.NavGraph
20 | import dev.fabik.bluetoothhid.ui.RequiresBluetoothPermission
21 | import dev.fabik.bluetoothhid.ui.theme.BluetoothHIDTheme
22 | import dev.fabik.bluetoothhid.ui.theme.configureWindow
23 | import dev.fabik.bluetoothhid.utils.JsEngineService
24 | import dev.fabik.bluetoothhid.utils.PreferenceStore
25 | import dev.fabik.bluetoothhid.utils.getPreferenceState
26 | import dev.fabik.bluetoothhid.utils.rememberJsEngineService
27 |
28 | val LocalController = staticCompositionLocalOf {
29 | null
30 | }
31 |
32 | val LocalJsEngineService = staticCompositionLocalOf {
33 | null
34 | }
35 |
36 | class MainActivity : ComponentActivity() {
37 |
38 | override fun onCreate(savedInstanceState: Bundle?) {
39 | super.onCreate(savedInstanceState)
40 |
41 | enableEdgeToEdge()
42 |
43 | // Configure window for high refresh rate and transparency
44 | configureWindow(window)
45 |
46 | setContent {
47 | BluetoothHIDTheme(window = window) {
48 | Surface(Modifier.fillMaxSize()) {
49 | val allowScreenRotation by getPreferenceState(PreferenceStore.ALLOW_SCREEN_ROTATION)
50 |
51 | allowScreenRotation?.let {
52 | LaunchedEffect(allowScreenRotation) {
53 | requestedOrientation = if (it) {
54 | ActivityInfo.SCREEN_ORIENTATION_FULL_SENSOR
55 | } else {
56 | ActivityInfo.SCREEN_ORIENTATION_NOSENSOR
57 | }
58 | }
59 | }
60 |
61 | RequiresBluetoothPermission {
62 | val bluetoothService = rememberBluetoothControllerService(this)
63 | val jsEngineService = rememberJsEngineService(this)
64 |
65 | CompositionLocalProvider(
66 | LocalController provides bluetoothService?.getController(),
67 | LocalJsEngineService provides jsEngineService
68 | ) {
69 | NavGraph()
70 | }
71 |
72 | PersistHistory()
73 | }
74 | }
75 | }
76 | }
77 | }
78 | }
79 |
--------------------------------------------------------------------------------
/app/src/main/java/dev/fabik/bluetoothhid/SettingsActivity.kt:
--------------------------------------------------------------------------------
1 | package dev.fabik.bluetoothhid
2 |
3 | import android.os.Bundle
4 | import androidx.activity.ComponentActivity
5 | import androidx.activity.compose.setContent
6 | import androidx.activity.enableEdgeToEdge
7 | import androidx.compose.foundation.layout.Box
8 | import androidx.compose.foundation.layout.fillMaxSize
9 | import androidx.compose.foundation.layout.padding
10 | import androidx.compose.material.icons.Icons
11 | import androidx.compose.material.icons.automirrored.filled.ArrowBack
12 | import androidx.compose.material3.ExperimentalMaterial3Api
13 | import androidx.compose.material3.Icon
14 | import androidx.compose.material3.IconButton
15 | import androidx.compose.material3.MaterialTheme
16 | import androidx.compose.material3.Scaffold
17 | import androidx.compose.material3.Surface
18 | import androidx.compose.material3.Text
19 | import androidx.compose.material3.TopAppBar
20 | import androidx.compose.material3.TopAppBarDefaults
21 | import androidx.compose.ui.Modifier
22 | import androidx.compose.ui.input.nestedscroll.nestedScroll
23 | import androidx.compose.ui.res.stringResource
24 | import dev.fabik.bluetoothhid.ui.SettingsDropdown
25 | import dev.fabik.bluetoothhid.ui.theme.BluetoothHIDTheme
26 | import dev.fabik.bluetoothhid.ui.theme.configureWindow
27 | import dev.fabik.bluetoothhid.ui.tooltip
28 |
29 | class SettingsActivity : ComponentActivity() {
30 |
31 | @OptIn(ExperimentalMaterial3Api::class)
32 | override fun onCreate(savedInstanceState: Bundle?) {
33 | super.onCreate(savedInstanceState)
34 |
35 | enableEdgeToEdge()
36 |
37 | // Configure window for high refresh rate and transparency
38 | configureWindow(window)
39 |
40 | setContent {
41 | BluetoothHIDTheme(window = window) {
42 | Surface(Modifier.fillMaxSize()) {
43 | val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()
44 |
45 | Scaffold(
46 | modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
47 | topBar = {
48 | TopAppBar(
49 | title = { Text(stringResource(R.string.settings)) },
50 | navigationIcon = {
51 | IconButton(
52 | onClick = { finishAfterTransition() },
53 | Modifier.tooltip(stringResource(R.string.back))
54 | ) {
55 | Icon(Icons.AutoMirrored.Filled.ArrowBack, "Back")
56 | }
57 | },
58 | actions = {
59 | SettingsDropdown()
60 | },
61 | scrollBehavior = scrollBehavior
62 | )
63 | }
64 | ) { padding ->
65 | Box(Modifier.padding(padding)) {
66 | SettingsContent()
67 | }
68 | }
69 | }
70 | }
71 | }
72 | }
73 |
74 | }
75 |
--------------------------------------------------------------------------------
/app/src/main/assets/keymaps/pl.layout:
--------------------------------------------------------------------------------
1 | ## Keymap overlay for a Polish Programmer keyboard
2 |
3 | ## -------------------------
4 | ## SOME USEFUL INFORMATIONS
5 | ## -------------------------
6 | ## ... for everyone who, just like me (winlin97), had no idea how it works, and want to make a custom keyboard layout.
7 | ## -------------------------
8 | ## The PC (thanks to the HID interface) see this application just like the physical keyboard.
9 | ## Application does not know the actual layout of the keyboard in the system.
10 | ## We must to call the language-appropriate characters just as if we were typing them on a physical keyboard with this layout.
11 | ## -------------------------
12 | ## This file contains a table of key mapping.
13 | ## Column order in the table: CHAR <-SPACE-> HID_KEY_SCANCODE <-SPACE-> MODIFIERS
14 | ## -------------------------
15 | ## <-SPACE-> is only a representation for spacebar (" " char), which is used by the program to separate individual values from every line in the table.
16 | ## -------------------------
17 | ## CHAR is just the letter, number or character, which we have to send to the computer.
18 | ## -------------------------
19 | ## HID_KEY_SCANCODE is a byte value, which corresponds to every key on the keyboard.
20 | ## Firstly you can look at "base.layout" file to see what scancode is e.g. for "numbers and letters"
21 | ## Important: "base.layout" is used with every custom layout. We don't have to add the chars to our table that this file already contains.
22 | ## More scancodes -> https://source.android.com/docs/core/interaction/input/keyboard-devices#hid-keyboard-and-keypad-page-0x07
23 | ## -------------------------
24 | ## MODIFIERS such as LCTRL, LSHIFT, LALT, etc. can be combined using the “or” (|) bit operation.
25 | ## The modifier value is stored using 1 byte [8 bits] - that allows to combine modifiers, setting the corresponding bits to 1.
26 | ## Each modifier corresponds to one bit in the byte, so no bits overlap.
27 | ## 00: none [bit: 0000 0000] // DEFAULT VALUE - all modifiers is off
28 | ## 01: LCTRL [bit: 0000 0001]
29 | ## 02: LSHIFT [bit: 0000 0010]
30 | ## 04: LALT [bit: 0000 0100]
31 | ## 08: LMETA [bit: 0000 1000]
32 | ## 10: RCTRL [bit: 0001 0000]
33 | ## 20: RSHIFT [bit: 0010 0000]
34 | ## 40: RALT [bit: 0100 0000] // "ALTGR" in polish programmers layout -> https://kbdlayout.info/KBDPL1/
35 | ## 80: RMETA [bit: 1000 0000]
36 | ## So, to get RALT and LSHIFT in the same time we have: 0x40 | 0x02 -> [bit: 0100 0010] -> 0x42
37 | ## -------------------------
38 | ## More info -> https://source.android.com/docs/core/interaction/input/keyboard-devices
39 | ## -------------------------
40 |
41 | ## START OF THE RIGHT TABLE:
42 | ą 04 40
43 | ć 06 40
44 | ę 08 40
45 | ł 0F 40
46 | ń 11 40
47 | ó 12 40
48 | ś 16 40
49 | ź 1B 40
50 | ż 1D 40
51 |
52 | Ą 04 42
53 | Ć 06 42
54 | Ę 08 42
55 | Ł 0F 42
56 | Ń 11 42
57 | Ó 12 42
58 | Ś 16 42
59 | Ź 1B 42
60 | Ż 1D 42
61 |
62 | | 31 02
63 | ? 38 02
64 | < 36 02
65 | > 37 02
66 | : 33 02
67 | " 34 02
68 | { 2F 02
69 | } 30 02
70 |
71 | \ 31 00
72 | / 38 00
73 | , 36 00
74 | . 37 00
75 | ; 33 00
76 | ' 34 00
77 | [ 2F 00
78 | ] 30 00
79 | - 2D 00
80 | = 2E 00
81 | ` 35 00
82 |
83 | ! 1E 02
84 | @ 1F 02
85 | # 20 02
86 | $ 21 02
87 | % 22 02
88 | ^ 23 02
89 | & 24 02
90 | * 25 02
91 | ( 26 02
92 | ) 27 02
93 | _ 2D 02
94 | + 2E 02
95 | ~ 35 02
96 |
--------------------------------------------------------------------------------
/app/src/main/java/dev/fabik/bluetoothhid/bt/Descriptor.kt:
--------------------------------------------------------------------------------
1 | package dev.fabik.bluetoothhid.bt
2 |
3 | import android.bluetooth.BluetoothHidDevice
4 | import android.bluetooth.BluetoothHidDeviceAppQosSettings
5 | import android.bluetooth.BluetoothHidDeviceAppSdpSettings
6 |
7 | object Descriptor {
8 | const val ID = 0x8
9 |
10 | private val KEYBOARD = byteArrayOf(
11 | 0x05.toByte(), 0x01.toByte(), // Usage Page (Generic Desktop)
12 | 0x09.toByte(), 0x06.toByte(), // Usage (Keyboard)
13 | 0xA1.toByte(), 0x01.toByte(), // Collection (Application)
14 | 0x85.toByte(), ID.toByte(), // REPORT_ID (Keyboard)
15 | 0x05.toByte(), 0x07.toByte(), // Usage Page (Key Codes)
16 | 0x19.toByte(), 0xe0.toByte(), // Usage Minimum (224)
17 | 0x29.toByte(), 0xe7.toByte(), // Usage Maximum (231)
18 | 0x15.toByte(), 0x00.toByte(), // Logical Minimum (0)
19 | 0x25.toByte(), 0x01.toByte(), // Logical Maximum (1)
20 | 0x75.toByte(), 0x01.toByte(), // Report Size (1)
21 | 0x95.toByte(), 0x08.toByte(), // Report Count (8)
22 | 0x81.toByte(), 0x02.toByte(), // Input (Data, Variable, Absolute)
23 |
24 | 0x05.toByte(), 0x08.toByte(), // Usage Page (LEDs)
25 | 0x19.toByte(), 0x01.toByte(), // Usage Minimum (Num Lock)
26 | 0x29.toByte(), 0x08.toByte(), // Usage Maximum (Kana + 3 custom)
27 | 0x95.toByte(), 0x08.toByte(), // Report Count (8)
28 | 0x75.toByte(), 0x01.toByte(), // Report Size (1)
29 | 0x91.toByte(), 0x02.toByte(), // Output (Data, Variable, Absolute)
30 |
31 | 0x95.toByte(), 0x01.toByte(), // Report Count (1)
32 | 0x75.toByte(), 0x08.toByte(), // Report Size (8)
33 | 0x81.toByte(), 0x01.toByte(), // Input (Constant) reserved byte(1)
34 |
35 | 0x95.toByte(), 0x01.toByte(), // Report Count (1)
36 | 0x75.toByte(), 0x08.toByte(), // Report Size (8)
37 | 0x15.toByte(), 0x00.toByte(), // Logical Minimum (0)
38 | 0x25.toByte(), 0x65.toByte(), // Logical Maximum (101)
39 | 0x05.toByte(), 0x07.toByte(), // Usage Page (Key codes)
40 | 0x19.toByte(), 0x00.toByte(), // Usage Minimum (0)
41 | 0x29.toByte(), 0x65.toByte(), // Usage Maximum (101)
42 | 0x81.toByte(), 0x00.toByte(), // Input (Data, Array) Key array(6 bytes)
43 | 0xc0.toByte() // End Collection (Application)
44 | )
45 |
46 | val SDP_RECORD = BluetoothHidDeviceAppSdpSettings(
47 | "Keyboard Input",
48 | "HID Device",
49 | "Android",
50 | BluetoothHidDevice.SUBCLASS1_KEYBOARD,
51 | KEYBOARD
52 | )
53 |
54 | val QOS_OUT = BluetoothHidDeviceAppQosSettings(
55 | BluetoothHidDeviceAppQosSettings.SERVICE_BEST_EFFORT,
56 | 800,
57 | 9,
58 | 0,
59 | 11250,
60 | BluetoothHidDeviceAppQosSettings.MAX
61 | )
62 | }
63 |
--------------------------------------------------------------------------------
/PRIVACY.md:
--------------------------------------------------------------------------------
1 | # Privacy
2 |
3 | This app is designed to scan barcodes with the phone camera and send the value to a device connected over Bluetooth.
4 | **It does not collect, transmit, or store any personal data.**
5 |
6 | ## Permissions
7 |
8 | The following table explains all the permissions that are used. Some permissions (marked with legacy) are only required on older Android versions and not relevant on newer devices.
9 |
10 | > [!IMPORTANT]
11 | > The app **does not require internet access** to function.
12 | > As a result it does not request or use any permissions related to internet connectivity (such as `android.permission.INTERNET` or `android.permission.ACCESS_NETWORK_STATE`).
13 |
14 |
15 | | Permission | Purpose | Explanation |
16 | | ------------------------------------------------------------------------------ | ------------------------------- | ------------------------------------------------------------------------------------------------------------------- |
17 | | `android.permission.BLUETOOTH`
*(only Android 11 and below)* | Connect to device (legacy) | Required to communicate with Bluetooth devices. |
18 | | `android.permission.BLUETOOTH_ADMIN`
*(only Android 11 and below)* | Discover devices (legacy) | Needed to initiate scanning for Bluetooth devices. |
19 | | `android.permission.ACCESS_COARSE_LOCATION`
*(only Android 11 and below)* | Bluetooth scan (legacy) | Needed on Android <= 11 due to how BLE scanning works on those versions. Not used for actual location tracking. |
20 | | `android.permission.ACCESS_FINE_LOCATION`
*(only Android 11 and below)* | Bluetooth scan (legacy) | Needed on Android <= 11 due to how BLE scanning works on those versions. Not used for actual location tracking. |
21 | | `android.permission.BLUETOOTH_SCAN` | Discover devices | Allows the app to scan for nearby Bluetooth devices on Android 12+ (API 31+) without requiring location permission. |
22 | | `android.permission.BLUETOOTH_CONNECT` | Connect to devices | Enables connecting to and communicating with the target device. |
23 | | `android.permission.FOREGROUND_SERVICE` | Service operation | Allows the app to run a foreground service, to ensure an uninterrupted communication with the target device. |
24 | | `android.permission.FOREGROUND_SERVICE_CONNECTED_DEVICE` | Long-running device interaction | Foreground service type. |
25 | | `android.permission.CAMERA` | Scan barcodes | Needed to scan barcodes with the phone camera. |
26 | | `android.permission.VIBRATE` | Haptic feedback | Used to provide tactile feedback after a new scan. |
27 | | `android.permission.POST_NOTIFICATIONS` | User notifications | Allows the app to show a notification that the foreground service is running. |
28 |
--------------------------------------------------------------------------------
/.idea/codeStyles/Project.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 | xmlns:android
18 |
19 | ^$
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 | xmlns:.*
29 |
30 | ^$
31 |
32 |
33 | BY_NAME
34 |
35 |
36 |
37 |
38 |
39 |
40 | .*:id
41 |
42 | http://schemas.android.com/apk/res/android
43 |
44 |
45 |
46 |
47 |
48 |
49 |
50 |
51 | .*:name
52 |
53 | http://schemas.android.com/apk/res/android
54 |
55 |
56 |
57 |
58 |
59 |
60 |
61 |
62 | name
63 |
64 | ^$
65 |
66 |
67 |
68 |
69 |
70 |
71 |
72 |
73 | style
74 |
75 | ^$
76 |
77 |
78 |
79 |
80 |
81 |
82 |
83 |
84 | .*
85 |
86 | ^$
87 |
88 |
89 | BY_NAME
90 |
91 |
92 |
93 |
94 |
95 |
96 | .*
97 |
98 | http://schemas.android.com/apk/res/android
99 |
100 |
101 | ANDROID_ATTRIBUTE_ORDER
102 |
103 |
104 |
105 |
106 |
107 |
108 | .*
109 |
110 | .*
111 |
112 |
113 | BY_NAME
114 |
115 |
116 |
117 |
118 |
119 |
120 |
121 |
122 |
123 |
--------------------------------------------------------------------------------
/app/build.gradle:
--------------------------------------------------------------------------------
1 | plugins {
2 | id 'com.android.application'
3 | id 'org.jetbrains.kotlin.android'
4 | id 'org.jetbrains.kotlin.plugin.compose'
5 | id 'org.jetbrains.kotlin.plugin.parcelize'
6 | }
7 |
8 | final def gitHash = providers.exec {
9 | commandLine('git', 'rev-parse', '--short=7', 'HEAD')
10 | }.standardOutput.asText.get().trim()
11 |
12 | kotlin {
13 | jvmToolchain(11)
14 | }
15 |
16 | android {
17 | namespace 'dev.fabik.bluetoothhid'
18 | compileSdkVersion 36
19 |
20 | defaultConfig {
21 | applicationId "dev.fabik.bluetoothhid"
22 | minSdk 28
23 | targetSdk 36
24 | versionCode 55
25 | versionName "2.1.1"
26 |
27 | buildConfigField "String", "GIT_COMMIT_HASH", "\"${gitHash}\""
28 |
29 | testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
30 | vectorDrawables {
31 | useSupportLibrary false
32 | }
33 | }
34 |
35 | buildTypes {
36 | release {
37 | shrinkResources true
38 | minifyEnabled true
39 | proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
40 | signingConfig signingConfigs.debug
41 | }
42 | }
43 |
44 | final def isBuildingBundle = gradle.startParameter.taskNames.any { it.toLowerCase().contains("bundle") }
45 |
46 | splits {
47 | abi {
48 | // split abi not supported for bundles
49 | enable !isBuildingBundle
50 |
51 | reset()
52 | include "x86", "x86_64", "armeabi-v7a", "arm64-v8a"
53 |
54 | universalApk true
55 | }
56 | }
57 |
58 | buildFeatures {
59 | compose true
60 | buildConfig true
61 | }
62 |
63 | composeOptions {
64 | kotlinCompilerExtensionVersion '1.4.2'
65 | }
66 |
67 | packagingOptions {
68 | resources {
69 | excludes += '/META-INF/{AL2.0,LGPL2.1}'
70 | excludes += "DebugProbesKt.bin"
71 | }
72 | }
73 |
74 | lint {
75 | disable "NullSafeMutableLiveData"
76 | }
77 | }
78 |
79 | configurations {
80 | all*.exclude group: 'androidx.appcompat', module: 'appcompat'
81 | }
82 |
83 | dependencies {
84 | implementation "androidx.javascriptengine:javascriptengine:1.0.0"
85 |
86 | implementation 'androidx.core:core-ktx:1.17.0'
87 | implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.10.0'
88 | implementation 'androidx.activity:activity-compose:1.12.0'
89 |
90 | implementation "androidx.compose.ui:ui:$compose_ui_version"
91 | implementation "androidx.compose.material:material-icons-extended:$compose_mat_version"
92 | implementation "androidx.compose.runtime:runtime-livedata:$compose_ui_version"
93 | implementation 'androidx.compose.material3:material3:1.4.0'
94 |
95 | implementation 'androidx.navigation:navigation-compose:2.9.6'
96 | implementation 'androidx.datastore:datastore-preferences:1.2.0'
97 |
98 | implementation "androidx.camera:camera-core:$camerax_version"
99 | implementation "androidx.camera:camera-camera2:$camerax_version"
100 | implementation "androidx.camera:camera-lifecycle:$camerax_version"
101 | implementation "androidx.camera:camera-view:$camerax_version"
102 | implementation "androidx.camera:camera-compose:$camerax_version"
103 |
104 | implementation 'io.github.zxing-cpp:android:2.3.0'
105 |
106 | implementation "com.google.accompanist:accompanist-permissions:$accomp_version"
107 |
108 | implementation "org.jetbrains.kotlin:kotlin-reflect:$kotlin_version"
109 |
110 | testImplementation 'junit:junit:4.13.2'
111 | testImplementation 'org.mockito:mockito-core:5.20.0'
112 | testImplementation 'org.mockito.kotlin:mockito-kotlin:6.1.0'
113 | testImplementation 'org.robolectric:robolectric:4.16'
114 |
115 | androidTestImplementation 'androidx.test.ext:junit:1.3.0'
116 | androidTestImplementation 'androidx.test.espresso:espresso-core:3.7.0'
117 | androidTestImplementation "androidx.compose.ui:ui-test-junit4:$compose_ui_version"
118 |
119 | implementation "androidx.compose.ui:ui-tooling-preview:$compose_ui_version"
120 | debugImplementation "androidx.compose.ui:ui-tooling:$compose_ui_version"
121 | debugImplementation "androidx.compose.ui:ui-test-manifest:$compose_ui_version"
122 | }
123 |
--------------------------------------------------------------------------------
/.idea/inspectionProfiles/Project_Default.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 |
45 |
46 |
47 |
48 |
49 |
50 |
51 |
52 |
53 |
54 |
55 |
56 |
57 |
58 |
59 |
60 |
61 |
62 |
63 |
64 |
65 |
66 |
67 |
68 |
69 |
70 |
--------------------------------------------------------------------------------
/app/src/main/java/dev/fabik/bluetoothhid/utils/ImageUtils.kt:
--------------------------------------------------------------------------------
1 | package dev.fabik.bluetoothhid.utils
2 |
3 | import android.graphics.Bitmap
4 | import androidx.camera.core.ImageProxy
5 | import kotlin.math.min
6 |
7 |
8 | object ImageUtils {
9 | // @see: androidx.camera.core.internal.utils.ImageUtil.yuv_420_888toNv21()
10 | fun yuv420888toNv21(image: ImageProxy): ByteArray {
11 | val yPlane = image.planes[0]
12 | val uPlane = image.planes[1]
13 | val vPlane = image.planes[2]
14 |
15 | val yBuffer = yPlane.buffer.also { it.rewind() }
16 | val uBuffer = uPlane.buffer.also { it.rewind() }
17 | val vBuffer = vPlane.buffer.also { it.rewind() }
18 |
19 | val ySize = yBuffer.remaining()
20 |
21 | var position = 0
22 | val nv21 = ByteArray(ySize + (image.width * image.height / 2))
23 |
24 | // Add the full y buffer to the array. If rowStride > 1, some padding may be skipped.
25 | for (row in 0 until image.height) {
26 | yBuffer[nv21, position, image.width]
27 | position += image.width
28 | yBuffer.position(
29 | min(
30 | ySize.toDouble(),
31 | (yBuffer.position() - image.width + yPlane.rowStride).toDouble()
32 | )
33 | .toInt()
34 | )
35 | }
36 |
37 | val chromaHeight = image.height / 2
38 | val chromaWidth = image.width / 2
39 | val vRowStride = vPlane.rowStride
40 | val uRowStride = uPlane.rowStride
41 | val vPixelStride = vPlane.pixelStride
42 | val uPixelStride = uPlane.pixelStride
43 |
44 | // Interleave the u and v frames, filling up the rest of the buffer. Use two line buffers to
45 | // perform faster bulk gets from the byte buffers.
46 | val vLineBuffer = ByteArray(vRowStride)
47 | val uLineBuffer = ByteArray(uRowStride)
48 | for (row in 0 until chromaHeight) {
49 | vBuffer[vLineBuffer, 0, min(
50 | vRowStride.toDouble(),
51 | vBuffer.remaining().toDouble()
52 | ).toInt()]
53 | uBuffer[uLineBuffer, 0, min(
54 | uRowStride.toDouble(),
55 | uBuffer.remaining().toDouble()
56 | ).toInt()]
57 | var vLineBufferPosition = 0
58 | var uLineBufferPosition = 0
59 | for (col in 0 until chromaWidth) {
60 | nv21[position++] = vLineBuffer[vLineBufferPosition]
61 | nv21[position++] = uLineBuffer[uLineBufferPosition]
62 | vLineBufferPosition += vPixelStride
63 | uLineBufferPosition += uPixelStride
64 | }
65 | }
66 |
67 | return nv21
68 | }
69 |
70 | fun yuv420888toARGB8888(image: ImageProxy): Bitmap {
71 | val width = image.width
72 | val height = image.height
73 | val argb = IntArray(width * height)
74 |
75 | val yPlane = image.planes[0]
76 | val uPlane = image.planes[1]
77 | val vPlane = image.planes[2]
78 |
79 | val yBuffer = yPlane.buffer.also { it.rewind() }
80 | val uBuffer = uPlane.buffer.also { it.rewind() }
81 | val vBuffer = vPlane.buffer.also { it.rewind() }
82 |
83 | val yRowStride = yPlane.rowStride
84 | val uvRowStride = uPlane.rowStride
85 | val uvPixelStride = uPlane.pixelStride
86 |
87 | for (y in 0 until height) {
88 | for (x in 0 until width) {
89 | val yIndex = y * yRowStride + x
90 | val yValue = (yBuffer[yIndex].toInt() and 0xFF)
91 |
92 | val uvIndex = (y / 2) * uvRowStride + (x / 2) * uvPixelStride
93 | val uValue = (uBuffer[uvIndex].toInt() and 0xFF) - 128
94 | val vValue = (vBuffer[uvIndex].toInt() and 0xFF) - 128
95 |
96 | val yF = yValue.toFloat()
97 | val r = (yF + 1.370705f * vValue).toInt().coerceIn(0, 255)
98 | val g = (yF - 0.337633f * uValue - 0.698001f * vValue).toInt().coerceIn(0, 255)
99 | val b = (yF + 1.732446f * uValue).toInt().coerceIn(0, 255)
100 |
101 | argb[y * width + x] =
102 | (0xFF shl 24) or (r shl 16) or (g shl 8) or b
103 | }
104 | }
105 |
106 | return Bitmap.createBitmap(argb, width, height, Bitmap.Config.ARGB_8888)
107 | }
108 |
109 | }
--------------------------------------------------------------------------------
/app/src/main/AndroidManifest.xml:
--------------------------------------------------------------------------------
1 |
2 |
4 |
5 |
9 |
12 |
15 |
18 |
21 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
34 |
37 |
38 |
42 |
43 |
44 |
47 |
50 |
51 |
62 |
63 |
67 |
68 |
71 |
72 |
78 |
79 |
80 |
81 |
82 |
83 |
86 |
87 |
88 |
93 |
94 |
95 |
96 |
97 |
98 |
99 |
104 |
107 |
108 |
109 |
110 |
111 |
--------------------------------------------------------------------------------
/app/src/main/java/dev/fabik/bluetoothhid/utils/ZXingAnalyzer.kt:
--------------------------------------------------------------------------------
1 | package dev.fabik.bluetoothhid.utils
2 |
3 | import android.graphics.Rect
4 | import android.util.Log
5 | import android.util.Size
6 | import androidx.camera.core.ImageAnalysis
7 | import androidx.camera.core.ImageProxy
8 | import zxingcpp.BarcodeReader
9 |
10 | class ZXingAnalyzer(
11 | initialOptions: BarcodeReader.Options = BarcodeReader.Options(),
12 | var scanDelay: Int,
13 | private val onAnalyze: (source: Size, rotation: Int) -> Unit,
14 | private val onResult: (barcodes: List, sourceImage: ImageProxy, source: Size) -> Unit,
15 | ) : ImageAnalysis.Analyzer {
16 |
17 | companion object {
18 | private const val TAG = "ZXingAnalyzer"
19 |
20 | // Order based on the mlkit order to preserve indexes
21 | // Needs to be kept in sync with the "code_types_values" string array resource
22 | private val FORMATS = arrayOf(
23 | BarcodeReader.Format.CODE_128 to "CODE_128",
24 | BarcodeReader.Format.CODE_39 to "CODE_39",
25 | BarcodeReader.Format.CODE_93 to "CODE_93",
26 | BarcodeReader.Format.CODABAR to "CODABAR",
27 | BarcodeReader.Format.DATA_MATRIX to "DATA_MATRIX",
28 | BarcodeReader.Format.EAN_13 to "EAN_13",
29 | BarcodeReader.Format.EAN_8 to "EAN_8",
30 | BarcodeReader.Format.ITF to "ITF",
31 | BarcodeReader.Format.QR_CODE to "QR_CODE",
32 | BarcodeReader.Format.UPC_A to "UPC_A",
33 | BarcodeReader.Format.UPC_E to "UPC_E",
34 | BarcodeReader.Format.PDF_417 to "PDF417",
35 | BarcodeReader.Format.AZTEC to "AZTEC",
36 | BarcodeReader.Format.DATA_BAR to "DATA_BAR",
37 | BarcodeReader.Format.DATA_BAR_EXPANDED to "DATA_BAR_EXPANDED",
38 | BarcodeReader.Format.DATA_BAR_LIMITED to "DATA_BAR_LIMITED",
39 | BarcodeReader.Format.DX_FILM_EDGE to "DX_FILM_EDGE",
40 | BarcodeReader.Format.MAXICODE to "MAXICODE",
41 | BarcodeReader.Format.MICRO_QR_CODE to "MICRO_QR_CODE",
42 | BarcodeReader.Format.RMQR_CODE to "RMQR_CODE"
43 | )
44 |
45 | fun index2Format(index: Int?): BarcodeReader.Format {
46 | return FORMATS.getOrNull(index ?: return BarcodeReader.Format.NONE)?.first
47 | ?: BarcodeReader.Format.NONE
48 | }
49 |
50 | fun format2Index(format: BarcodeReader.Format): Int {
51 | return FORMATS.indexOfFirst {
52 | it.first == format
53 | }
54 | }
55 |
56 | fun format2String(format: BarcodeReader.Format): String {
57 | return FORMATS.firstOrNull {
58 | it.first == format
59 | }?.second ?: "UNKNOWN"
60 | }
61 |
62 | fun index2String(formatIndex: Int): String {
63 | return FORMATS.getOrNull(formatIndex)?.second ?: "UNKNOWN"
64 | }
65 | }
66 |
67 | var cropRect: Rect? = null
68 | var currentScanRect: androidx.compose.ui.geometry.Rect? = null
69 |
70 | private val reader = BarcodeReader(initialOptions)
71 | private var lastAnalyzedTimeStamp = 0L
72 |
73 | fun setOptions(options: BarcodeReader.Options) {
74 | reader.options = options
75 | }
76 |
77 | override fun analyze(image: ImageProxy) {
78 | val currentTime = System.currentTimeMillis()
79 | val deltaTime = currentTime - lastAnalyzedTimeStamp
80 |
81 | val source =
82 | if (image.imageInfo.rotationDegrees == 90 || image.imageInfo.rotationDegrees == 270) {
83 | Size(image.height, image.width)
84 | } else {
85 | Size(image.width, image.height)
86 | }
87 |
88 | // Close image directly if wait time has not passed
89 | if (deltaTime < scanDelay) {
90 | image.close()
91 | } else {
92 | runCatching {
93 | cropRect?.let {
94 | image.setCropRect(it)
95 | }
96 |
97 | image.use {
98 | val results = reader.read(image)
99 |
100 | // Add delay only after something was detected
101 | if (results.isNotEmpty()) {
102 | lastAnalyzedTimeStamp = currentTime
103 | }
104 |
105 | onResult(results, image, source)
106 | }
107 | }.onFailure {
108 | Log.e(TAG, "Error analyzing image!", it)
109 | }
110 | }
111 |
112 | onAnalyze(source, image.imageInfo.rotationDegrees)
113 | }
114 |
115 | }
--------------------------------------------------------------------------------
/app/src/main/java/dev/fabik/bluetoothhid/ui/Tooltip.kt:
--------------------------------------------------------------------------------
1 | @file:Suppress("INVISIBLE_REFERENCE", "INVISIBLE_MEMBER")
2 |
3 | package dev.fabik.bluetoothhid.ui
4 |
5 | import androidx.compose.animation.core.MutableTransitionState
6 | import androidx.compose.animation.core.animateFloat
7 | import androidx.compose.animation.core.rememberTransition
8 | import androidx.compose.animation.core.tween
9 | import androidx.compose.foundation.gestures.awaitEachGesture
10 | import androidx.compose.foundation.gestures.awaitFirstDown
11 | import androidx.compose.foundation.gestures.awaitLongPressOrCancellation
12 | import androidx.compose.foundation.layout.Box
13 | import androidx.compose.foundation.layout.padding
14 | import androidx.compose.material3.Card
15 | import androidx.compose.material3.CardDefaults
16 | import androidx.compose.material3.MaterialTheme
17 | import androidx.compose.material3.Text
18 | import androidx.compose.material3.internal.DropdownMenuPositionProvider
19 | import androidx.compose.runtime.Composable
20 | import androidx.compose.runtime.LaunchedEffect
21 | import androidx.compose.runtime.MutableState
22 | import androidx.compose.runtime.getValue
23 | import androidx.compose.runtime.mutableStateOf
24 | import androidx.compose.runtime.remember
25 | import androidx.compose.ui.Modifier
26 | import androidx.compose.ui.composed
27 | import androidx.compose.ui.draw.alpha
28 | import androidx.compose.ui.input.pointer.PointerEventPass
29 | import androidx.compose.ui.input.pointer.PointerEventType
30 | import androidx.compose.ui.input.pointer.pointerInput
31 | import androidx.compose.ui.platform.LocalDensity
32 | import androidx.compose.ui.unit.DpOffset
33 | import androidx.compose.ui.unit.dp
34 | import androidx.compose.ui.window.Popup
35 | import androidx.compose.ui.window.PopupProperties
36 | import dev.fabik.bluetoothhid.ui.theme.Typography
37 |
38 | @Composable
39 | fun Tooltip(
40 | expanded: MutableState,
41 | content: @Composable () -> Unit
42 | ) {
43 | val expandedStates = remember { MutableTransitionState(false) }
44 |
45 | LaunchedEffect(expanded.value) {
46 | expandedStates.targetState = expanded.value
47 | }
48 |
49 | if (expandedStates.currentState || expandedStates.targetState) {
50 | val density = LocalDensity.current
51 |
52 | Popup(
53 | onDismissRequest = { expanded.value = false },
54 | popupPositionProvider = DropdownMenuPositionProvider(DpOffset.Zero, density),
55 | properties = PopupProperties(focusable = true),
56 | ) {
57 | TooltipContent(
58 | expandedStates,
59 | content
60 | )
61 | }
62 | }
63 | }
64 |
65 | @Composable
66 | fun TooltipContent(
67 | expandedStates: MutableTransitionState,
68 | content: @Composable () -> Unit
69 | ) {
70 | val transition = rememberTransition(expandedStates, "Tooltip")
71 |
72 | val alpha by transition.animateFloat(
73 | transitionSpec = {
74 | if (true isTransitioningTo false) {
75 | tween(durationMillis = 300, delayMillis = 800)
76 | } else {
77 | tween(durationMillis = 200)
78 | }
79 | },
80 | label = "Tooltip alpha"
81 | ) { expanded ->
82 | if (expanded) 1f else 0f
83 | }
84 |
85 | Card(
86 | modifier = Modifier
87 | .alpha(alpha)
88 | .padding(4.dp),
89 | shape = MaterialTheme.shapes.extraSmall,
90 | elevation = CardDefaults.elevatedCardElevation(4.dp),
91 | ) {
92 | Box(modifier = Modifier.padding(8.dp)) {
93 | content()
94 | }
95 | }
96 | }
97 |
98 | fun Modifier.tooltip(text: String) = composed {
99 | val showTooltip = remember { mutableStateOf(false) }
100 |
101 | Tooltip(showTooltip) {
102 | Text(text, style = Typography.bodyLarge)
103 | }
104 |
105 | pointerInput(Unit) {
106 | awaitEachGesture {
107 | val down = awaitFirstDown(requireUnconsumed = false)
108 | val longPress = awaitLongPressOrCancellation(down.id)
109 |
110 | // Check if not cancelled
111 | if (longPress != null) {
112 | showTooltip.value = true
113 |
114 | // Wait for up event and consume it
115 | while (true) {
116 | val event = awaitPointerEvent(PointerEventPass.Initial)
117 | if (event.type == PointerEventType.Release) {
118 | event.changes.forEach { it.consume() }
119 | showTooltip.value = false // Hide animation has 800ms delay
120 | break
121 | }
122 | }
123 | }
124 | }
125 | }
126 | }
127 |
--------------------------------------------------------------------------------
/app/src/main/java/dev/fabik/bluetoothhid/ui/theme/Theme.kt:
--------------------------------------------------------------------------------
1 | package dev.fabik.bluetoothhid.ui.theme
2 |
3 | import android.os.Build
4 | import android.view.Window
5 | import androidx.compose.foundation.isSystemInDarkTheme
6 | import androidx.compose.material3.MaterialTheme
7 | import androidx.compose.material3.darkColorScheme
8 | import androidx.compose.material3.dynamicDarkColorScheme
9 | import androidx.compose.material3.dynamicLightColorScheme
10 | import androidx.compose.material3.lightColorScheme
11 | import androidx.compose.runtime.Composable
12 | import androidx.compose.runtime.SideEffect
13 | import androidx.compose.runtime.getValue
14 | import androidx.compose.ui.graphics.luminance
15 | import androidx.compose.ui.platform.LocalContext
16 | import androidx.compose.ui.platform.LocalView
17 | import androidx.core.view.WindowCompat
18 | import dev.fabik.bluetoothhid.utils.PreferenceStore
19 | import dev.fabik.bluetoothhid.utils.Theme
20 | import dev.fabik.bluetoothhid.utils.rememberEnumPreference
21 | import dev.fabik.bluetoothhid.utils.rememberPreference
22 |
23 | private val DarkColorScheme = darkColorScheme(
24 | primary = Purple80,
25 | secondary = PurpleGrey80,
26 | tertiary = Pink80
27 | )
28 |
29 | private val LightColorScheme = lightColorScheme(
30 | primary = Purple40,
31 | secondary = PurpleGrey40,
32 | tertiary = Pink40
33 |
34 | /* Other default colors to override
35 | background = Color(0xFFFFFBFE),
36 | surface = Color(0xFFFFFBFE),
37 | onPrimary = Color.White,
38 | onSecondary = Color.White,
39 | onTertiary = Color.White,
40 | onBackground = Color(0xFF1C1B1F),
41 | onSurface = Color(0xFF1C1B1F),
42 | */
43 | )
44 |
45 | /**
46 | * Configures window properties for better performance and transparency.
47 | * - Disables navigation bar contrast enforcement for true transparency
48 | * - Enables high refresh rate (90/120 Hz) if supported
49 | *
50 | * @param window The activity window to configure
51 | */
52 | fun configureWindow(window: Window) {
53 | // Disable navigation bar contrast enforcement to allow true transparency
54 | if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
55 | window.isNavigationBarContrastEnforced = false
56 | }
57 |
58 | // Enable high refresh rate (90/120 Hz)
59 | if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
60 | @Suppress("DEPRECATION")
61 | val display = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
62 | window.context.display
63 | } else {
64 | window.windowManager.defaultDisplay
65 | }
66 |
67 | display?.let {
68 | val modes = it.supportedModes
69 | val highRefreshMode = modes.maxByOrNull { mode -> mode.refreshRate }
70 | highRefreshMode?.let { mode ->
71 | window.attributes = window.attributes.apply {
72 | preferredDisplayModeId = mode.modeId
73 | }
74 | }
75 | }
76 | }
77 | }
78 |
79 | /**
80 | * Controls the appearance of system bars (status bar and navigation bar) based on theme.
81 | * Adjusts icon colors to be light or dark depending on background luminance.
82 | *
83 | * @param window The activity window to control
84 | */
85 | @Composable
86 | fun SystemBarsController(window: Window) {
87 | val view = LocalView.current
88 | val colorScheme = MaterialTheme.colorScheme
89 | val isLightTheme = colorScheme.background.luminance() > 0.5f
90 |
91 | SideEffect {
92 | val insetsController = WindowCompat.getInsetsController(window, view)
93 | insetsController.isAppearanceLightStatusBars = isLightTheme
94 | insetsController.isAppearanceLightNavigationBars = isLightTheme
95 | }
96 | }
97 |
98 | @Composable
99 | fun BluetoothHIDTheme(
100 | window: Window? = null, // Activity window for system bars control
101 | content: @Composable () -> Unit
102 | ) {
103 | // Use blocking preferences to prevent theme flash on startup
104 | val prefDarkTheme by rememberEnumPreference(PreferenceStore.THEME)
105 | val prefDynamicColor by rememberPreference(PreferenceStore.DYNAMIC_THEME)
106 |
107 | val dark = when (prefDarkTheme) {
108 | Theme.LIGHT -> false
109 | Theme.DARK -> true
110 | Theme.SYSTEM -> isSystemInDarkTheme()
111 | }
112 |
113 | val colorScheme = when {
114 | prefDynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
115 | val context = LocalContext.current
116 | if (dark) dynamicDarkColorScheme(context)
117 | else dynamicLightColorScheme(context)
118 | }
119 | dark -> DarkColorScheme
120 | else -> LightColorScheme
121 | }
122 |
123 | MaterialTheme(
124 | colorScheme = colorScheme,
125 | typography = Typography
126 | ) {
127 | // Control system bars appearance if window is provided
128 | window?.let { SystemBarsController(it) }
129 |
130 | content()
131 | }
132 | }
133 |
--------------------------------------------------------------------------------
/app/src/main/java/dev/fabik/bluetoothhid/ui/Navigation.kt:
--------------------------------------------------------------------------------
1 | package dev.fabik.bluetoothhid.ui
2 |
3 | import android.widget.Toast
4 | import androidx.activity.compose.BackHandler
5 | import androidx.activity.compose.LocalActivity
6 | import androidx.compose.runtime.Composable
7 | import androidx.compose.runtime.CompositionLocalProvider
8 | import androidx.compose.runtime.LaunchedEffect
9 | import androidx.compose.runtime.getValue
10 | import androidx.compose.runtime.mutableStateOf
11 | import androidx.compose.runtime.remember
12 | import androidx.compose.runtime.rememberCoroutineScope
13 | import androidx.compose.runtime.setValue
14 | import androidx.compose.runtime.staticCompositionLocalOf
15 | import androidx.compose.ui.res.stringResource
16 | import androidx.lifecycle.compose.collectAsStateWithLifecycle
17 | import androidx.navigation.NavHostController
18 | import androidx.navigation.compose.NavHost
19 | import androidx.navigation.compose.composable
20 | import androidx.navigation.compose.rememberNavController
21 | import dev.fabik.bluetoothhid.Devices
22 | import dev.fabik.bluetoothhid.History
23 | import dev.fabik.bluetoothhid.LocalController
24 | import dev.fabik.bluetoothhid.R
25 | import dev.fabik.bluetoothhid.Scanner
26 | import dev.fabik.bluetoothhid.utils.ZXingAnalyzer
27 | import kotlinx.coroutines.CoroutineScope
28 | import kotlinx.coroutines.Dispatchers
29 | import kotlinx.coroutines.delay
30 | import kotlinx.coroutines.launch
31 |
32 | object Routes {
33 | const val Devices = "Devices"
34 | const val Main = "Main"
35 | const val History = "History"
36 | }
37 |
38 | val LocalNavigation = staticCompositionLocalOf {
39 | error("No Navigation provided")
40 | }
41 |
42 | @Composable
43 | fun NavGraph() {
44 | val controller = LocalController.current
45 | val activity = LocalActivity.current
46 | val scope = rememberCoroutineScope()
47 | val navController = rememberNavController()
48 |
49 | var canExit by remember { mutableStateOf(false) }
50 | val exitString = stringResource(R.string.exit_confirm)
51 |
52 | // Handles shortcut to scanner
53 | val startDestination = remember {
54 | when (activity?.intent?.dataString) {
55 | "Scanner" -> Routes.Main
56 | "History" -> Routes.History
57 | else -> Routes.Devices
58 | }
59 | }
60 |
61 | val currentDevice by controller?.currentDevice?.collectAsStateWithLifecycle()
62 | ?: remember { mutableStateOf(null) }
63 |
64 | CompositionLocalProvider(LocalNavigation provides navController) {
65 | NavHost(
66 | navController,
67 | startDestination,
68 | ) {
69 | composable(Routes.Devices) {
70 | Devices()
71 |
72 | // Confirm back presses to exit the app
73 | BackHandler {
74 | if (canExit) {
75 | activity?.finishAfterTransition()
76 | } else {
77 | canExit = true
78 | Toast.makeText(activity, exitString, Toast.LENGTH_SHORT).show()
79 | scope.launch {
80 | delay(2000)
81 | canExit = false
82 | }
83 | }
84 | }
85 | }
86 |
87 | composable(Routes.Main) {
88 | Scanner(currentDevice) { text, format, imageName ->
89 | scope.launch {
90 | val barcodeType = format?.let { ZXingAnalyzer.index2String(it) }
91 | controller?.sendString(
92 | text,
93 | true,
94 | "SCAN",
95 | null,
96 | barcodeType,
97 | imageName = imageName
98 | )
99 | }
100 | }
101 |
102 | BackHandler {
103 | // Disconnect from device and navigate back to devices list
104 | controller?.disconnect()
105 | if (!navController.navigateUp()) {
106 | navController.popBackStack()
107 | navController.navigate(Routes.Devices)
108 | }
109 | }
110 | }
111 |
112 | composable(Routes.History) {
113 | // Go back either by pressing the back button or the back arrow
114 | val onBack: () -> Unit = {
115 | if (!navController.navigateUp()) {
116 | navController.popBackStack()
117 | navController.navigate(Routes.Devices)
118 | }
119 | }
120 |
121 | History(onBack) { historyEntry ->
122 | CoroutineScope(Dispatchers.IO).launch {
123 | val barcodeType = historyEntry.format.let { ZXingAnalyzer.index2String(it) }
124 | controller?.sendString(historyEntry.value, true, "HISTORY", historyEntry.timestamp, barcodeType)
125 | }
126 | }
127 |
128 | BackHandler(onBack = onBack)
129 | }
130 | }
131 | }
132 |
133 | // Listen for changes in the current device
134 | LaunchedEffect(currentDevice) {
135 | // When connected to a device, navigate to the scanner
136 | if (currentDevice != null) {
137 | // Single-top is used to avoid creating multiple instances of the scanner
138 | navController.navigate(Routes.Main) {
139 | launchSingleTop = true
140 | }
141 | }
142 | }
143 | }
--------------------------------------------------------------------------------
/gradlew:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env sh
2 |
3 | #
4 | # Copyright 2015 the original author or authors.
5 | #
6 | # Licensed under the Apache License, Version 2.0 (the "License");
7 | # you may not use this file except in compliance with the License.
8 | # You may obtain a copy of the License at
9 | #
10 | # https://www.apache.org/licenses/LICENSE-2.0
11 | #
12 | # Unless required by applicable law or agreed to in writing, software
13 | # distributed under the License is distributed on an "AS IS" BASIS,
14 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15 | # See the License for the specific language governing permissions and
16 | # limitations under the License.
17 | #
18 |
19 | ##############################################################################
20 | ##
21 | ## Gradle start up script for UN*X
22 | ##
23 | ##############################################################################
24 |
25 | # Attempt to set APP_HOME
26 | # Resolve links: $0 may be a link
27 | PRG="$0"
28 | # Need this for relative symlinks.
29 | while [ -h "$PRG" ] ; do
30 | ls=`ls -ld "$PRG"`
31 | link=`expr "$ls" : '.*-> \(.*\)$'`
32 | if expr "$link" : '/.*' > /dev/null; then
33 | PRG="$link"
34 | else
35 | PRG=`dirname "$PRG"`"/$link"
36 | fi
37 | done
38 | SAVED="`pwd`"
39 | cd "`dirname \"$PRG\"`/" >/dev/null
40 | APP_HOME="`pwd -P`"
41 | cd "$SAVED" >/dev/null
42 |
43 | APP_NAME="Gradle"
44 | APP_BASE_NAME=`basename "$0"`
45 |
46 | # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
47 | DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
48 |
49 | # Use the maximum available, or set MAX_FD != -1 to use that value.
50 | MAX_FD="maximum"
51 |
52 | warn () {
53 | echo "$*"
54 | }
55 |
56 | die () {
57 | echo
58 | echo "$*"
59 | echo
60 | exit 1
61 | }
62 |
63 | # OS specific support (must be 'true' or 'false').
64 | cygwin=false
65 | msys=false
66 | darwin=false
67 | nonstop=false
68 | case "`uname`" in
69 | CYGWIN* )
70 | cygwin=true
71 | ;;
72 | Darwin* )
73 | darwin=true
74 | ;;
75 | MINGW* )
76 | msys=true
77 | ;;
78 | NONSTOP* )
79 | nonstop=true
80 | ;;
81 | esac
82 |
83 | CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
84 |
85 |
86 | # Determine the Java command to use to start the JVM.
87 | if [ -n "$JAVA_HOME" ] ; then
88 | if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
89 | # IBM's JDK on AIX uses strange locations for the executables
90 | JAVACMD="$JAVA_HOME/jre/sh/java"
91 | else
92 | JAVACMD="$JAVA_HOME/bin/java"
93 | fi
94 | if [ ! -x "$JAVACMD" ] ; then
95 | die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
96 |
97 | Please set the JAVA_HOME variable in your environment to match the
98 | location of your Java installation."
99 | fi
100 | else
101 | JAVACMD="java"
102 | which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
103 |
104 | Please set the JAVA_HOME variable in your environment to match the
105 | location of your Java installation."
106 | fi
107 |
108 | # Increase the maximum file descriptors if we can.
109 | if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then
110 | MAX_FD_LIMIT=`ulimit -H -n`
111 | if [ $? -eq 0 ] ; then
112 | if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then
113 | MAX_FD="$MAX_FD_LIMIT"
114 | fi
115 | ulimit -n $MAX_FD
116 | if [ $? -ne 0 ] ; then
117 | warn "Could not set maximum file descriptor limit: $MAX_FD"
118 | fi
119 | else
120 | warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT"
121 | fi
122 | fi
123 |
124 | # For Darwin, add options to specify how the application appears in the dock
125 | if $darwin; then
126 | GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\""
127 | fi
128 |
129 | # For Cygwin or MSYS, switch paths to Windows format before running java
130 | if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then
131 | APP_HOME=`cygpath --path --mixed "$APP_HOME"`
132 | CLASSPATH=`cygpath --path --mixed "$CLASSPATH"`
133 |
134 | JAVACMD=`cygpath --unix "$JAVACMD"`
135 |
136 | # We build the pattern for arguments to be converted via cygpath
137 | ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null`
138 | SEP=""
139 | for dir in $ROOTDIRSRAW ; do
140 | ROOTDIRS="$ROOTDIRS$SEP$dir"
141 | SEP="|"
142 | done
143 | OURCYGPATTERN="(^($ROOTDIRS))"
144 | # Add a user-defined pattern to the cygpath arguments
145 | if [ "$GRADLE_CYGPATTERN" != "" ] ; then
146 | OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)"
147 | fi
148 | # Now convert the arguments - kludge to limit ourselves to /bin/sh
149 | i=0
150 | for arg in "$@" ; do
151 | CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -`
152 | CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option
153 |
154 | if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition
155 | eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"`
156 | else
157 | eval `echo args$i`="\"$arg\""
158 | fi
159 | i=`expr $i + 1`
160 | done
161 | case $i in
162 | 0) set -- ;;
163 | 1) set -- "$args0" ;;
164 | 2) set -- "$args0" "$args1" ;;
165 | 3) set -- "$args0" "$args1" "$args2" ;;
166 | 4) set -- "$args0" "$args1" "$args2" "$args3" ;;
167 | 5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;;
168 | 6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;;
169 | 7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;;
170 | 8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;;
171 | 9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;;
172 | esac
173 | fi
174 |
175 | # Escape application args
176 | save () {
177 | for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done
178 | echo " "
179 | }
180 | APP_ARGS=`save "$@"`
181 |
182 | # Collect all arguments for the java command, following the shell quoting and substitution rules
183 | eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS"
184 |
185 | exec "$JAVACMD" "$@"
186 |
--------------------------------------------------------------------------------
/app/src/main/java/dev/fabik/bluetoothhid/bt/BluetoothService.kt:
--------------------------------------------------------------------------------
1 | package dev.fabik.bluetoothhid.bt
2 |
3 | import android.app.Activity
4 | import android.app.Notification
5 | import android.app.NotificationChannel
6 | import android.app.NotificationManager
7 | import android.app.PendingIntent
8 | import android.app.Service
9 | import android.content.ComponentName
10 | import android.content.Context
11 | import android.content.Intent
12 | import android.content.ServiceConnection
13 | import android.content.pm.ServiceInfo
14 | import android.os.Binder
15 | import android.os.Build
16 | import android.os.IBinder
17 | import android.util.Log
18 | import androidx.compose.runtime.Composable
19 | import androidx.compose.runtime.DisposableEffect
20 | import androidx.compose.runtime.mutableStateOf
21 | import androidx.compose.runtime.remember
22 | import androidx.lifecycle.Lifecycle
23 | import dev.fabik.bluetoothhid.MainActivity
24 | import dev.fabik.bluetoothhid.R
25 | import dev.fabik.bluetoothhid.utils.ComposableLifecycle
26 | import kotlinx.coroutines.CoroutineScope
27 | import kotlinx.coroutines.Dispatchers
28 | import kotlinx.coroutines.launch
29 | import kotlinx.coroutines.runBlocking
30 |
31 | class BluetoothService : Service() {
32 |
33 | companion object {
34 | private const val CHANNEL_ID = "bt_hid_service"
35 |
36 | const val ACTION_REGISTER = "register"
37 | const val ACTION_STOP = "stop"
38 | }
39 |
40 | private val binder = LocalBinder()
41 | private var controller: BluetoothController? = null
42 |
43 | inner class LocalBinder : Binder() {
44 | fun getController(): BluetoothController? = controller
45 | }
46 |
47 | override fun onBind(intent: Intent?): IBinder = binder
48 |
49 | override fun onCreate() {
50 | controller = BluetoothController(this)
51 |
52 | // Register controller once when service is created
53 | CoroutineScope(Dispatchers.IO).launch {
54 | controller?.register()
55 | }
56 | }
57 |
58 | override fun onDestroy() {
59 | controller?.unregister()
60 | controller = null
61 | }
62 |
63 | override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
64 | // Debug actions to stop service and register controller again
65 | if (intent?.action == ACTION_REGISTER) {
66 | runBlocking {
67 | controller?.register()
68 | }
69 | } else if (intent?.action == ACTION_STOP) {
70 | controller?.unregister()
71 | stopForeground(STOP_FOREGROUND_REMOVE)
72 | stopSelf()
73 | return START_NOT_STICKY
74 | }
75 |
76 | val pendingIntent =
77 | Intent(this, MainActivity::class.java).let { notificationIntent ->
78 | PendingIntent.getActivity(
79 | this, 0, notificationIntent,
80 | PendingIntent.FLAG_IMMUTABLE
81 | )
82 | }
83 |
84 | val channel = NotificationChannel(
85 | CHANNEL_ID,
86 | getString(R.string.bt_hid_service),
87 | NotificationManager.IMPORTANCE_LOW
88 | )
89 |
90 | val manager = getSystemService(NOTIFICATION_SERVICE) as NotificationManager
91 | manager.createNotificationChannel(channel)
92 |
93 | val notification = Notification.Builder(this, CHANNEL_ID)
94 | .setContentTitle(getString(R.string.bt_hid_service))
95 | .setContentText(getString(R.string.service_running))
96 | .setSmallIcon(android.R.drawable.stat_sys_data_bluetooth)
97 | .setCategory(Notification.CATEGORY_SERVICE)
98 | .setContentIntent(pendingIntent)
99 | .build()
100 | notification.flags = Notification.FLAG_NO_CLEAR or Notification.FLAG_ONGOING_EVENT
101 |
102 | if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
103 | // Catch ForegroundServiceStartNotAllowedException when app is in background
104 | runCatching {
105 | startForeground(
106 | 1,
107 | notification,
108 | ServiceInfo.FOREGROUND_SERVICE_TYPE_CONNECTED_DEVICE
109 | )
110 | }.onFailure {
111 | Log.e("BTService", "Error starting foreground service!", it)
112 | stopForeground(STOP_FOREGROUND_REMOVE)
113 | stopSelf()
114 | }
115 | } else {
116 | startForeground(1, notification)
117 | }
118 |
119 | return START_STICKY
120 | }
121 |
122 | }
123 |
124 | @Composable
125 | fun rememberBluetoothControllerService(
126 | context: Context,
127 | startStop: Boolean = true
128 | ): BluetoothService.LocalBinder? {
129 | val serviceBinder = remember { mutableStateOf(null) }
130 | val intent = remember { Intent(context, BluetoothService::class.java) }
131 |
132 | DisposableEffect(Unit) {
133 | val serviceConnection =
134 | object : ServiceConnection {
135 | override fun onServiceConnected(name: ComponentName?, service: IBinder?) {
136 | serviceBinder.value = service as BluetoothService.LocalBinder?
137 | }
138 |
139 | override fun onServiceDisconnected(name: ComponentName?) {
140 | serviceBinder.value = null
141 | }
142 | }
143 |
144 | context.bindService(
145 | intent,
146 | serviceConnection,
147 | if (startStop) Activity.BIND_AUTO_CREATE else 0
148 | )
149 |
150 | onDispose {
151 | context.unbindService(serviceConnection)
152 | serviceBinder.value = null
153 | }
154 | }
155 |
156 | if (startStop) {
157 | ComposableLifecycle { _, event ->
158 | when (event) {
159 | Lifecycle.Event.ON_RESUME ->
160 | // Catch ForegroundServiceStartNotAllowedException when app is in background
161 | runCatching {
162 | context.startForegroundService(intent)
163 | }.onFailure {
164 | Log.e("BTService", "Failed to start service", it)
165 | }
166 | Lifecycle.Event.ON_DESTROY -> {
167 | if ((context as? Activity)?.isChangingConfigurations == false) {
168 | context.stopService(intent)
169 | }
170 | }
171 |
172 | else -> {}
173 | }
174 | }
175 | }
176 |
177 | return serviceBinder.value
178 | }
--------------------------------------------------------------------------------
/app/src/main/java/dev/fabik/bluetoothhid/ui/Permissions.kt:
--------------------------------------------------------------------------------
1 | package dev.fabik.bluetoothhid.ui
2 |
3 | import android.content.Context
4 | import android.content.Intent
5 | import android.location.LocationManager
6 | import android.os.Build
7 | import android.provider.Settings
8 | import androidx.compose.foundation.layout.*
9 | import androidx.compose.material.icons.Icons
10 | import androidx.compose.material.icons.filled.BluetoothDisabled
11 | import androidx.compose.material.icons.outlined.NoPhotography
12 | import androidx.compose.material3.FilledTonalButton
13 | import androidx.compose.material3.Icon
14 | import androidx.compose.material3.Text
15 | import androidx.compose.runtime.*
16 | import androidx.compose.ui.Alignment
17 | import androidx.compose.ui.Modifier
18 | import androidx.compose.ui.platform.LocalContext
19 | import androidx.compose.ui.res.stringResource
20 | import androidx.compose.ui.unit.dp
21 | import com.google.accompanist.permissions.ExperimentalPermissionsApi
22 | import com.google.accompanist.permissions.isGranted
23 | import com.google.accompanist.permissions.rememberMultiplePermissionsState
24 | import com.google.accompanist.permissions.rememberPermissionState
25 | import dev.fabik.bluetoothhid.R
26 | import dev.fabik.bluetoothhid.ui.theme.Typography
27 | import dev.fabik.bluetoothhid.utils.SystemBroadcastReceiver
28 |
29 | @OptIn(ExperimentalPermissionsApi::class)
30 | @Composable
31 | fun RequiresBluetoothPermission(
32 | content: @Composable () -> Unit
33 | ) {
34 | val permissions = mutableListOf()
35 |
36 | if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
37 | permissions.add(android.Manifest.permission.BLUETOOTH_SCAN)
38 | permissions.add(android.Manifest.permission.BLUETOOTH_CONNECT)
39 | } else {
40 | permissions.add(android.Manifest.permission.BLUETOOTH)
41 | permissions.add(android.Manifest.permission.BLUETOOTH_ADMIN)
42 | }
43 |
44 | val bluetoothPermission = rememberMultiplePermissionsState(permissions)
45 |
46 | if (bluetoothPermission.allPermissionsGranted) {
47 | content()
48 | } else {
49 | Column(
50 | Modifier
51 | .padding(8.dp)
52 | .fillMaxSize(),
53 | verticalArrangement = Arrangement.spacedBy(16.dp, Alignment.CenterVertically),
54 | horizontalAlignment = Alignment.CenterHorizontally
55 | ) {
56 | Icon(
57 | imageVector = Icons.Default.BluetoothDisabled,
58 | contentDescription = null,
59 | modifier = Modifier.size(64.dp)
60 | )
61 |
62 | Text(stringResource(R.string.bluetooth_permission))
63 |
64 | FilledTonalButton(onClick = {
65 | bluetoothPermission.launchMultiplePermissionRequest()
66 | }) {
67 | Text(stringResource(R.string.request_again))
68 | }
69 | }
70 |
71 | SideEffect {
72 | bluetoothPermission.launchMultiplePermissionRequest()
73 | }
74 | }
75 | }
76 |
77 | @OptIn(ExperimentalPermissionsApi::class)
78 | @Composable
79 | fun BoxScope.RequiresCameraPermission(
80 | content: @Composable () -> Unit
81 | ) {
82 | val cameraPermission = rememberPermissionState(android.Manifest.permission.CAMERA)
83 |
84 | if (cameraPermission.status.isGranted) {
85 | content()
86 | } else {
87 | Column(
88 | Modifier
89 | .padding(8.dp)
90 | .align(Alignment.Center)
91 | .fillMaxWidth(),
92 | verticalArrangement = Arrangement.spacedBy(16.dp, Alignment.CenterVertically),
93 | horizontalAlignment = Alignment.CenterHorizontally
94 | ) {
95 | Icon(
96 | imageVector = Icons.Outlined.NoPhotography,
97 | contentDescription = null,
98 | modifier = Modifier.size(64.dp)
99 | )
100 |
101 | Text(stringResource(R.string.camera_permission))
102 |
103 | FilledTonalButton(onClick = {
104 | cameraPermission.launchPermissionRequest()
105 | }) {
106 | Text(stringResource(R.string.request_permission))
107 | }
108 | }
109 | }
110 | }
111 |
112 | @OptIn(ExperimentalPermissionsApi::class)
113 | @Composable
114 | fun RequireLocationPermission(
115 | content: @Composable () -> Unit
116 | ) {
117 | val context = LocalContext.current
118 |
119 | if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
120 | // On android 12+ we don't need location permission to scan for bluetooth devices
121 | content()
122 | } else {
123 | val locationPermission = rememberMultiplePermissionsState(
124 | listOf(
125 | android.Manifest.permission.ACCESS_COARSE_LOCATION,
126 | android.Manifest.permission.ACCESS_FINE_LOCATION,
127 | )
128 | )
129 |
130 | if (!locationPermission.allPermissionsGranted) {
131 | Column {
132 | Text(stringResource(R.string.location_permission), style = Typography.labelMedium)
133 |
134 | FilledTonalButton(onClick = {
135 | locationPermission.launchMultiplePermissionRequest()
136 | }) {
137 | Text(stringResource(R.string.request_permission))
138 | }
139 | }
140 | } else {
141 | val locationManager = remember {
142 | context.getSystemService(Context.LOCATION_SERVICE) as LocationManager
143 | }
144 |
145 | var enabledState by remember { mutableStateOf(locationManager.isLocationEnabled) }
146 |
147 | SystemBroadcastReceiver(LocationManager.MODE_CHANGED_ACTION) {
148 | it?.let {
149 | enabledState = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
150 | it.getBooleanExtra(LocationManager.EXTRA_LOCATION_ENABLED, false)
151 | } else {
152 | locationManager.isLocationEnabled
153 | }
154 | }
155 | }
156 |
157 | if (!enabledState) {
158 | Column {
159 | Text(stringResource(R.string.location_enable), style = Typography.labelMedium)
160 |
161 | FilledTonalButton(onClick = {
162 | context.startActivity(Intent(Settings.ACTION_LOCATION_SOURCE_SETTINGS))
163 | }) {
164 | Text(stringResource(R.string.open_location_settings))
165 | }
166 | }
167 | } else {
168 | content()
169 | }
170 | }
171 | }
172 | }
173 |
--------------------------------------------------------------------------------
/app/src/main/res/drawable/ic_launcher_foreground.xml:
--------------------------------------------------------------------------------
1 |
6 |
11 |
15 |
19 |
23 |
27 |
28 |
29 |
--------------------------------------------------------------------------------
/app/src/main/java/dev/fabik/bluetoothhid/utils/JsEngineService.kt:
--------------------------------------------------------------------------------
1 | package dev.fabik.bluetoothhid.utils
2 |
3 | import android.annotation.SuppressLint
4 | import android.app.Service
5 | import android.content.ComponentName
6 | import android.content.Context
7 | import android.content.Intent
8 | import android.content.ServiceConnection
9 | import android.os.Binder
10 | import android.os.IBinder
11 | import android.util.Log
12 | import androidx.compose.runtime.Composable
13 | import androidx.compose.runtime.DisposableEffect
14 | import androidx.compose.runtime.getValue
15 | import androidx.compose.runtime.mutableStateOf
16 | import androidx.compose.runtime.remember
17 | import androidx.javascriptengine.JavaScriptSandbox
18 | import androidx.lifecycle.Lifecycle
19 | import com.google.common.util.concurrent.FutureCallback
20 | import com.google.common.util.concurrent.Futures
21 | import java.util.concurrent.Executors
22 | import java.util.concurrent.TimeUnit
23 | import kotlin.coroutines.resume
24 | import kotlin.coroutines.suspendCoroutine
25 | import kotlin.runCatching
26 |
27 | class JsEngineService : Service() {
28 | companion object {
29 | const val TAG: String = "JsEngine"
30 | }
31 |
32 | private val binder = LocalBinder()
33 | private var jsSandbox: JavaScriptSandbox? = null
34 | private var isInitialized = false
35 |
36 | override fun onCreate() {
37 | Log.d(TAG, "Initializing js sandbox...")
38 |
39 | if (isInitialized) {
40 | Log.w(TAG, "Already initialized (skipping)")
41 | return
42 | }
43 |
44 | if (!JavaScriptSandbox.isSupported()) {
45 | Log.w(TAG, "JsSandbox is not supported!")
46 | return
47 | }
48 |
49 | runCatching {
50 | val jsSandboxFuture = JavaScriptSandbox.createConnectedInstanceAsync(applicationContext)
51 |
52 | Futures.addCallback(jsSandboxFuture, object : FutureCallback {
53 | override fun onSuccess(result: JavaScriptSandbox) {
54 | jsSandbox = result
55 | isInitialized = true
56 | }
57 |
58 | override fun onFailure(t: Throwable) {
59 | Log.e(TAG, "Failed to connect to js sandbox service", t)
60 | }
61 |
62 | }, Executors.newSingleThreadExecutor())
63 | }.onFailure {
64 | Log.e(TAG, "Failed to initialize js sandbox", it)
65 | jsSandbox?.close()
66 | isInitialized = true
67 | }
68 | }
69 |
70 | override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
71 | Log.d(TAG, "Start command received")
72 | return START_STICKY
73 | }
74 |
75 | override fun onDestroy() {
76 | Log.d(TAG, "OnDestroy called")
77 | isInitialized = false
78 | jsSandbox?.close()
79 | }
80 |
81 | override fun onBind(intent: Intent?): IBinder = binder
82 |
83 | @SuppressLint("RequiresFeature")
84 | suspend fun evaluate(code: String, onOutput: ((String) -> Unit)? = null): String? {
85 | var result: String? = "Error: Unable to create isolate!"
86 |
87 | jsSandbox?.createIsolate()?.let {
88 | if (jsSandbox?.isFeatureSupported(JavaScriptSandbox.JS_FEATURE_CONSOLE_MESSAGING) == true) {
89 | it.setConsoleCallback(Executors.newSingleThreadExecutor()) { message ->
90 | onOutput?.invoke(message.toString())
91 | }
92 | } else {
93 | onOutput?.invoke("(Console logging is not supported on this system)")
94 | }
95 |
96 | it.addOnTerminatedCallback(Executors.newSingleThreadExecutor()) { info ->
97 | Log.d(TAG, "Isolate terminated with $info")
98 | onOutput?.invoke("Isolate terminated with $info")
99 | }
100 |
101 | onOutput?.invoke("--- Execution started ---")
102 |
103 | val start = System.currentTimeMillis()
104 | val future = it.evaluateJavaScriptAsync(code)
105 |
106 | result = suspendCoroutine {
107 | Executors.newSingleThreadExecutor().submit {
108 | runCatching {
109 | it.resume(future.get(1, TimeUnit.SECONDS))
110 | }.onFailure { err ->
111 | Log.e(TAG, "Failed to evaluate code", err)
112 | onOutput?.invoke(err.cause?.message ?: err.message ?: err.toString())
113 | it.resume(null)
114 | }
115 | }
116 | }
117 |
118 | onOutput?.invoke("--- Execution finished (${System.currentTimeMillis() - start}ms) ---")
119 |
120 | it.clearConsoleCallback()
121 | it.close()
122 | }
123 |
124 | return result
125 | }
126 |
127 | inner class LocalBinder : Binder() {
128 | private suspend fun evaluate(code: String, onOutput: ((String) -> Unit)? = null) =
129 | this@JsEngineService.evaluate(code, onOutput)
130 |
131 | suspend fun evaluateTemplate(
132 | code: String,
133 | value: String,
134 | type: String,
135 | onOutput: ((String) -> Unit)? = null
136 | ): String? {
137 | val escapedVal = value.replace("\\", "\\\\").replace("\"", "\\\"")
138 |
139 | val template = """
140 | const format = "$type";
141 | const code = "$escapedVal";
142 | $code""".trimIndent()
143 |
144 | return evaluate(template, onOutput)
145 | }
146 | }
147 |
148 | }
149 |
150 | @Composable
151 | fun rememberJsEngineService(context: Context): JsEngineService.LocalBinder? {
152 | val jsEnabled by context.getPreferenceState(PreferenceStore.ENABLE_JS)
153 |
154 | val serviceBinder = remember { mutableStateOf(null) }
155 | val intent = remember { Intent(context, JsEngineService::class.java) }
156 |
157 | if (jsEnabled == true) {
158 | DisposableEffect(Unit) {
159 | val serviceConnection = object : ServiceConnection {
160 | override fun onServiceConnected(name: ComponentName?, service: IBinder?) {
161 | serviceBinder.value = service as JsEngineService.LocalBinder?
162 | }
163 |
164 | override fun onServiceDisconnected(name: ComponentName?) {
165 | serviceBinder.value = null
166 | }
167 | }
168 |
169 | context.bindService(intent, serviceConnection, Context.BIND_AUTO_CREATE)
170 |
171 | onDispose {
172 | context.unbindService(serviceConnection)
173 | serviceBinder.value = null
174 | }
175 | }
176 | }
177 |
178 | ComposableLifecycle { _, event ->
179 | when (event) {
180 | Lifecycle.Event.ON_START -> if (jsEnabled == true) context.startService(intent)
181 | Lifecycle.Event.ON_DESTROY -> context.stopService(intent) // always try to stop service
182 | else -> {}
183 | }
184 | }
185 |
186 | return serviceBinder.value
187 | }
--------------------------------------------------------------------------------
/app/src/test/java/dev/fabik/bluetoothhid/utils/TemplateProcessorTest.kt:
--------------------------------------------------------------------------------
1 | package dev.fabik.bluetoothhid.utils
2 |
3 | import org.junit.Assert.assertEquals
4 | import org.junit.Assert.assertTrue
5 | import org.junit.Test
6 | import org.junit.runner.RunWith
7 | import org.robolectric.RobolectricTestRunner
8 |
9 | @RunWith(RobolectricTestRunner::class)
10 | class TemplateProcessorTest {
11 |
12 | @Test
13 | fun testBasicCodePlaceholder() {
14 | // Test simple {CODE} replacement
15 | val result = TemplateProcessor.processTemplate(
16 | data = "12345",
17 | template = "Barcode: {CODE}",
18 | mode = TemplateProcessor.TemplateMode.HID
19 | )
20 | assertEquals("Barcode: 12345", result)
21 | }
22 |
23 | @Test
24 | fun testCodeWithHexEncoding() {
25 | // Test {CODE_HEX} encoding
26 | val result = TemplateProcessor.processTemplate(
27 | data = "AB",
28 | template = "{CODE_HEX}",
29 | mode = TemplateProcessor.TemplateMode.HID
30 | )
31 | // "AB" -> hex: 4142 (lowercase in HID mode)
32 | assertEquals("4142", result)
33 | }
34 |
35 | @Test
36 | fun testCodeWithBase64Encoding() {
37 | // Test {CODE_B64} encoding
38 | val result = TemplateProcessor.processTemplate(
39 | data = "test",
40 | template = "{CODE_B64}",
41 | mode = TemplateProcessor.TemplateMode.HID
42 | )
43 | // "test" in base64
44 | assertEquals("dGVzdA==", result)
45 | }
46 |
47 | @Test
48 | fun testCodeWithMultipleEncodings() {
49 | // Test {CODE_HEX_B64} - hex then base64
50 | val result = TemplateProcessor.processTemplate(
51 | data = "A",
52 | template = "{CODE_HEX_B64}",
53 | mode = TemplateProcessor.TemplateMode.HID
54 | )
55 | // "A" -> hex: "61" -> base64
56 | assertTrue(result.isNotEmpty())
57 | }
58 |
59 | @Test
60 | fun testDateTimePlaceholders() {
61 | // Test that {DATE}, {TIME}, {DATETIME} get replaced
62 | val result = TemplateProcessor.processTemplate(
63 | data = "test",
64 | template = "{DATE} {TIME}",
65 | mode = TemplateProcessor.TemplateMode.HID
66 | )
67 | // Should not contain placeholders
68 | assertTrue(!result.contains("{DATE}"))
69 | assertTrue(!result.contains("{TIME}"))
70 | }
71 |
72 | @Test
73 | fun testCodeTypePlaceholder() {
74 | // Test {CODE_TYPE} replacement
75 | val result = TemplateProcessor.processTemplate(
76 | data = "123",
77 | template = "{CODE} Type: {CODE_TYPE}",
78 | mode = TemplateProcessor.TemplateMode.HID,
79 | barcodeType = "QR_CODE"
80 | )
81 | assertEquals("123 Type: QR_CODE", result)
82 | }
83 |
84 | @Test
85 | fun testScanSourcePlaceholder() {
86 | // Test {SCAN_SOURCE} replacement
87 | val result = TemplateProcessor.processTemplate(
88 | data = "456",
89 | template = "{CODE} Source: {SCAN_SOURCE}",
90 | mode = TemplateProcessor.TemplateMode.HID,
91 | from = "CAMERA"
92 | )
93 | assertEquals("456 Source: CAMERA", result)
94 | }
95 |
96 | @Test
97 | fun testScannerIDPlaceholder() {
98 | // Test {SCANNER_ID} replacement
99 | val result = TemplateProcessor.processTemplate(
100 | data = "789",
101 | template = "{CODE} Scanner: {SCANNER_ID}",
102 | mode = TemplateProcessor.TemplateMode.HID,
103 | scannerId = "DEVICE123"
104 | )
105 | assertEquals("789 Scanner: DEVICE123", result)
106 | }
107 |
108 | @Test
109 | fun testSpacePlaceholderHIDMode() {
110 | // Test {SPACE} in HID mode
111 | val result = TemplateProcessor.processTemplate(
112 | data = "X",
113 | template = "{CODE}{SPACE}{CODE}",
114 | mode = TemplateProcessor.TemplateMode.HID
115 | )
116 | assertEquals("X X", result)
117 | }
118 |
119 | @Test
120 | fun testTabPlaceholderRFCOMMMode() {
121 | // Test {TAB} in RFCOMM mode
122 | val result = TemplateProcessor.processTemplate(
123 | data = "Y",
124 | template = "{CODE}{TAB}{CODE}",
125 | mode = TemplateProcessor.TemplateMode.RFCOMM
126 | )
127 | assertEquals("Y\tY", result)
128 | }
129 |
130 | @Test
131 | fun testEnterPlaceholderRFCOMMMode() {
132 | // Test {ENTER} in RFCOMM mode
133 | val result = TemplateProcessor.processTemplate(
134 | data = "Z",
135 | template = "{CODE}{ENTER}",
136 | mode = TemplateProcessor.TemplateMode.RFCOMM
137 | )
138 | assertEquals("Z\r\n", result)
139 | }
140 |
141 | @Test
142 | fun testUnsupportedPlaceholdersInHIDMode() {
143 | // Test that unsupported placeholders in HID mode are replaced with markers
144 | val result = TemplateProcessor.processTemplate(
145 | data = "test",
146 | template = "{CODE}{TAB}",
147 | mode = TemplateProcessor.TemplateMode.HID,
148 | preserveUnsupportedPlaceholders = false
149 | )
150 | // {TAB} should be replaced with a marker in HID mode
151 | assertEquals("test\uFFFD{TAB}", result)
152 | }
153 |
154 | @Test
155 | fun testPreserveUnsupportedPlaceholders() {
156 | // Test preserveUnsupported flag
157 | val result = TemplateProcessor.processTemplate(
158 | data = "test",
159 | template = "{CODE}{TAB}",
160 | mode = TemplateProcessor.TemplateMode.HID,
161 | preserveUnsupportedPlaceholders = true
162 | )
163 | // {TAB} should be kept as-is when preserveUnsupported is true
164 | assertEquals("test{TAB}", result)
165 | }
166 |
167 | @Test
168 | fun testGlobalHexEncoding() {
169 | // Test {GLOBAL_HEX} applies to entire output
170 | val result = TemplateProcessor.processTemplate(
171 | data = "AB",
172 | template = "{GLOBAL_HEX}{CODE}",
173 | mode = TemplateProcessor.TemplateMode.HID
174 | )
175 | // "AB" -> hex (lowercase in HID)
176 | assertEquals("4142", result)
177 | }
178 |
179 | @Test
180 | fun testComplexTemplate() {
181 | // Test combination of multiple placeholders
182 | val result = TemplateProcessor.processTemplate(
183 | data = "12345",
184 | template = "Type:{CODE_TYPE},Code:{CODE},Source:{SCAN_SOURCE}",
185 | mode = TemplateProcessor.TemplateMode.RFCOMM,
186 | barcodeType = "EAN_13",
187 | from = "SCAN"
188 | )
189 | assertEquals("Type:EAN_13,Code:12345,Source:SCAN", result)
190 | }
191 |
192 | @Test
193 | fun testEmptyTemplate() {
194 | // Test that template with only CODE works
195 | val result = TemplateProcessor.processTemplate(
196 | data = "test",
197 | template = "{CODE}",
198 | mode = TemplateProcessor.TemplateMode.HID
199 | )
200 | assertEquals("test", result)
201 | }
202 | }
203 |
--------------------------------------------------------------------------------
/app/src/main/java/dev/fabik/bluetoothhid/ui/Preference.kt:
--------------------------------------------------------------------------------
1 | package dev.fabik.bluetoothhid.ui
2 |
3 | import androidx.compose.foundation.clickable
4 | import androidx.compose.material3.Icon
5 | import androidx.compose.material3.ListItem
6 | import androidx.compose.material3.Switch
7 | import androidx.compose.material3.Text
8 | import androidx.compose.runtime.Composable
9 | import androidx.compose.runtime.getValue
10 | import androidx.compose.runtime.setValue
11 | import androidx.compose.ui.Modifier
12 | import androidx.compose.ui.draw.alpha
13 | import androidx.compose.ui.graphics.vector.ImageVector
14 | import dev.fabik.bluetoothhid.utils.PreferenceStore
15 | import dev.fabik.bluetoothhid.utils.rememberEnumPreference
16 | import dev.fabik.bluetoothhid.utils.rememberPreferenceNull
17 |
18 |
19 | @Composable
20 | fun ButtonPreference(
21 | title: String,
22 | desc: String,
23 | icon: ImageVector? = null,
24 | extra: (@Composable () -> Unit)? = null,
25 | enabled: Boolean = true,
26 | onClick: () -> Unit = {}
27 | ) {
28 | ListItem(
29 | headlineContent = { Text(title) },
30 | modifier = Modifier
31 | .alpha(if (enabled) 1f else 0.38f)
32 | .clickable(enabled = enabled, onClick = onClick),
33 | supportingContent = { Text(desc) },
34 | leadingContent = icon?.let {
35 | { Icon(icon, null) }
36 | },
37 | trailingContent = extra
38 | )
39 | }
40 |
41 | @Composable
42 | fun SwitchPreference(
43 | title: String,
44 | desc: String,
45 | icon: ImageVector? = null,
46 | preference: PreferenceStore.Preference
47 | ) {
48 | var checked by rememberPreferenceNull(preference)
49 |
50 | SwitchPreference(title, desc, icon, checked) {
51 | checked = it
52 | }
53 | }
54 |
55 | @Composable
56 | fun SwitchPreference(
57 | title: String,
58 | desc: String,
59 | icon: ImageVector? = null,
60 | checked: Boolean?,
61 | onToggle: (Boolean) -> Unit
62 | ) {
63 | ButtonPreference(
64 | title, desc, icon, {
65 | checked?.let { c ->
66 | Switch(
67 | checked = c,
68 | onCheckedChange = null
69 | )
70 | }
71 | }
72 | ) {
73 | checked?.let {
74 | onToggle(!it)
75 | }
76 | }
77 | }
78 |
79 | @Composable
80 | fun > ComboBoxEnumPreference(
81 | title: String,
82 | desc: String,
83 | values: Array,
84 | icon: ImageVector? = null,
85 | preference: PreferenceStore.EnumPref,
86 | enabled: Boolean = true,
87 | onReset: () -> Unit = {},
88 | ) {
89 | var selectedEnum by rememberEnumPreference(preference)
90 |
91 | ComboBoxPreference(
92 | title,
93 | desc,
94 | selectedEnum.ordinal,
95 | values,
96 | icon,
97 | enabled,
98 | onReset = { selectedEnum = preference.getDefaultEnum(); onReset() }
99 | ) {
100 | selectedEnum = preference.fromOrdinal(it)
101 | }
102 | }
103 |
104 | @Composable
105 | fun ComboBoxPreference(
106 | title: String,
107 | desc: String,
108 | selectedItem: Int?,
109 | values: Array,
110 | icon: ImageVector? = null,
111 | enabled: Boolean = true,
112 | onReset: () -> Unit,
113 | onSelect: (Int) -> Unit
114 | ) {
115 | val dialogState = rememberDialogState()
116 |
117 | selectedItem?.let { s ->
118 | ComboBoxDialog(dialogState, title, s, values, onReset = onReset, description = desc) {
119 | onSelect(it)
120 | }
121 | }
122 |
123 | ButtonPreference(title, values[selectedItem ?: 0], icon, enabled = enabled) {
124 | dialogState.open()
125 | }
126 | }
127 |
128 | @Composable
129 | fun SliderPreference(
130 | title: String,
131 | desc: String,
132 | valueFormat: String = "%f",
133 | range: ClosedFloatingPointRange,
134 | steps: Int = 0,
135 | icon: ImageVector? = null,
136 | enabled: Boolean = true,
137 | preference: PreferenceStore.Preference
138 | ) {
139 | var value by rememberPreferenceNull(preference)
140 |
141 | SliderPreference(
142 | title,
143 | desc,
144 | valueFormat,
145 | value,
146 | steps,
147 | range,
148 | icon,
149 | enabled,
150 | onReset = { value = preference.defaultValue }
151 | ) {
152 | value = it
153 | }
154 | }
155 |
156 | @Composable
157 | fun SliderPreference(
158 | title: String,
159 | desc: String,
160 | valueFormat: String = "%f",
161 | value: Float?,
162 | steps: Int = 0,
163 | range: ClosedFloatingPointRange,
164 | icon: ImageVector? = null,
165 | enabled: Boolean = true,
166 | onReset: () -> Unit,
167 | onSelect: (Float) -> Unit
168 | ) {
169 | val dialogState = rememberDialogState()
170 |
171 | value?.let {
172 | SliderDialog(
173 | dialogState,
174 | title,
175 | valueFormat,
176 | value,
177 | range,
178 | steps,
179 | onReset = onReset,
180 | description = desc
181 | ) {
182 | onSelect(it)
183 | }
184 | }
185 |
186 | ButtonPreference(title, valueFormat.format(value), icon, enabled = enabled) {
187 | dialogState.open()
188 | }
189 | }
190 |
191 | @Composable
192 | fun CheckBoxPreference(
193 | title: String,
194 | desc: String,
195 | descLong: String? = desc,
196 | valueStrings: Array,
197 | icon: ImageVector? = null,
198 | preference: PreferenceStore.Preference>
199 | ) {
200 | var value by rememberPreferenceNull(preference)
201 |
202 | CheckBoxPreference(
203 | title,
204 | desc,
205 | descLong,
206 | selectedValues = value?.map { v -> v.toInt() }?.toSet(),
207 | valueStrings,
208 | icon,
209 | onReset = { value = preference.defaultValue }
210 | ) {
211 | value = it.map { v -> v.toString() }.toSet()
212 | }
213 | }
214 |
215 | @Composable
216 | fun CheckBoxPreference(
217 | title: String,
218 | desc: String,
219 | descLong: String? = desc,
220 | selectedValues: Set?,
221 | valueStrings: Array,
222 | icon: ImageVector? = null,
223 | onReset: () -> Unit,
224 | onSelect: (Set) -> Unit
225 | ) {
226 | val dialogState = rememberDialogState()
227 |
228 | selectedValues?.let {
229 | CheckBoxDialog(
230 | dialogState,
231 | title,
232 | it,
233 | valueStrings,
234 | onReset = onReset,
235 | description = descLong
236 | ) { v ->
237 | onSelect(v.toSet())
238 | }
239 | }
240 |
241 | ButtonPreference(title, desc, icon) {
242 | dialogState.open()
243 | }
244 | }
245 |
246 | @Composable
247 | fun TextBoxPreference(
248 | title: String,
249 | desc: String,
250 | descLong: String? = desc,
251 | validator: (String) -> String? = { null },
252 | icon: ImageVector? = null,
253 | enabled: Boolean = true,
254 | preference: PreferenceStore.Preference
255 | ) {
256 | var value by rememberPreferenceNull(preference)
257 |
258 | TextBoxPreference(
259 | title,
260 | desc,
261 | descLong,
262 | value,
263 | validator,
264 | icon,
265 | enabled,
266 | onReset = { value = preference.defaultValue }) {
267 | value = it
268 | }
269 | }
270 |
271 | @Composable
272 | fun TextBoxPreference(
273 | title: String,
274 | desc: String,
275 | descLong: String? = desc,
276 | value: String?,
277 | validator: (String) -> String? = { null },
278 | icon: ImageVector? = null,
279 | enabled: Boolean = true,
280 | onReset: () -> Unit,
281 | onSelect: (String) -> Unit
282 | ) {
283 | val dialogState = rememberDialogState()
284 |
285 | value?.let {
286 | TextBoxDialog(
287 | dialogState,
288 | title,
289 | it,
290 | validator = validator,
291 | onReset = onReset,
292 | description = descLong
293 | ) { v ->
294 | onSelect(v)
295 | }
296 | }
297 |
298 | ButtonPreference(title, if (value.isNullOrEmpty()) desc else value, icon, enabled = enabled) {
299 | dialogState.open()
300 | }
301 | }
302 |
--------------------------------------------------------------------------------
/app/src/main/java/dev/fabik/bluetoothhid/ui/JsEditor.kt:
--------------------------------------------------------------------------------
1 | package dev.fabik.bluetoothhid.ui
2 |
3 | import androidx.compose.foundation.layout.Column
4 | import androidx.compose.foundation.layout.Row
5 | import androidx.compose.foundation.layout.fillMaxWidth
6 | import androidx.compose.foundation.layout.height
7 | import androidx.compose.foundation.layout.padding
8 | import androidx.compose.foundation.rememberScrollState
9 | import androidx.compose.foundation.verticalScroll
10 | import androidx.compose.material3.Button
11 | import androidx.compose.material3.Card
12 | import androidx.compose.material3.DropdownMenuItem
13 | import androidx.compose.material3.ExperimentalMaterial3Api
14 | import androidx.compose.material3.ExposedDropdownMenuAnchorType
15 | import androidx.compose.material3.ExposedDropdownMenuBox
16 | import androidx.compose.material3.ExposedDropdownMenuDefaults
17 | import androidx.compose.material3.MaterialTheme
18 | import androidx.compose.material3.Surface
19 | import androidx.compose.material3.Text
20 | import androidx.compose.material3.TextField
21 | import androidx.compose.runtime.Composable
22 | import androidx.compose.runtime.derivedStateOf
23 | import androidx.compose.runtime.getValue
24 | import androidx.compose.runtime.mutableStateOf
25 | import androidx.compose.runtime.remember
26 | import androidx.compose.runtime.rememberCoroutineScope
27 | import androidx.compose.runtime.rememberUpdatedState
28 | import androidx.compose.runtime.setValue
29 | import androidx.compose.ui.Alignment
30 | import androidx.compose.ui.Modifier
31 | import androidx.compose.ui.platform.LocalContext
32 | import androidx.compose.ui.res.stringArrayResource
33 | import androidx.compose.ui.res.stringResource
34 | import androidx.compose.ui.text.TextStyle
35 | import androidx.compose.ui.text.input.TextFieldValue
36 | import androidx.compose.ui.tooling.preview.Preview
37 | import androidx.compose.ui.unit.dp
38 | import dev.fabik.bluetoothhid.R
39 | import dev.fabik.bluetoothhid.utils.PreferenceStore
40 | import dev.fabik.bluetoothhid.utils.rememberJsEngineService
41 | import dev.fabik.bluetoothhid.utils.rememberPreference
42 | import kotlinx.coroutines.launch
43 |
44 | @Composable
45 | fun JavaScriptEditorDialog(jsDialog: DialogState) {
46 | var codePreference by rememberPreference(PreferenceStore.JS_CODE)
47 | var codeText by remember { mutableStateOf(codePreference) }
48 |
49 | val outString = stringResource(R.string.press_run_to_evaluate)
50 | var outputText by remember(jsDialog.openState) { mutableStateOf(outString) }
51 |
52 | ConfirmDialog(
53 | dialogState = jsDialog,
54 | title = stringResource(R.string.custom_javascript),
55 | onDismiss = {
56 | close()
57 | }, onConfirm = {
58 | close()
59 | codePreference = codeText
60 | }
61 | ) {
62 | val context = LocalContext.current
63 | val scope = rememberCoroutineScope()
64 | val jsEngine = rememberJsEngineService(context)
65 |
66 | JavaScriptEditor(
67 | initialCode = codePreference,
68 | onRunClicked = { code, value, type ->
69 | scope.launch {
70 | outputText = ""
71 |
72 | val result = jsEngine?.evaluateTemplate(code, value, type) { message ->
73 | outputText += message + "\n"
74 | }
75 |
76 | outputText += when {
77 | result == null -> "JSEngine not initialized or unsupported! Make sure that you have enabled the feature."
78 | result.isEmpty() -> "Empty result (Make sure that your code has a string as the last statement)"
79 | else -> result
80 | }
81 | }
82 | },
83 | onEdit = { codeText = it },
84 | outputText = outputText,
85 | )
86 | }
87 | }
88 |
89 | @OptIn(ExperimentalMaterial3Api::class)
90 | @Composable
91 | fun JavaScriptEditor(
92 | initialCode: String,
93 | onRunClicked: (String, String, String) -> Unit,
94 | onEdit: (String) -> Unit,
95 | outputText: String
96 | ) {
97 | var codeText by remember { mutableStateOf(TextFieldValue(initialCode)) }
98 | var valueText by remember { mutableStateOf(TextFieldValue("")) }
99 | var typeText by remember { mutableStateOf("") }
100 |
101 | val currentOnRunClicked by rememberUpdatedState(onRunClicked)
102 | val currentOnEdit by rememberUpdatedState(onEdit)
103 |
104 | Column(Modifier.verticalScroll(rememberScrollState())) {
105 | Text(stringResource(R.string.editor_desc))
106 |
107 | Text(
108 | stringResource(R.string.editor),
109 | style = MaterialTheme.typography.titleMedium,
110 | modifier = Modifier.padding(top = 8.dp)
111 | )
112 |
113 | // JavaScript code editor
114 | TextField(
115 | value = codeText,
116 | onValueChange = { codeText = it; currentOnEdit(it.text) },
117 | textStyle = TextStyle(),
118 | modifier = Modifier
119 | .fillMaxWidth()
120 | .height(200.dp)
121 | .padding(bottom = 16.dp)
122 | )
123 |
124 | Text(stringResource(R.string.debug), style = MaterialTheme.typography.titleMedium)
125 |
126 | // Input fields
127 | Row(
128 | verticalAlignment = Alignment.CenterVertically,
129 | modifier = Modifier.fillMaxWidth()
130 | ) {
131 | TextField(
132 | value = valueText,
133 | onValueChange = { valueText = it },
134 | modifier = Modifier
135 | .weight(0.5f)
136 | .padding(end = 8.dp),
137 | placeholder = { Text(stringResource(R.string.code)) }
138 | )
139 |
140 | val options = stringArrayResource(R.array.code_types_values)
141 | val filterOpts by remember {
142 | derivedStateOf {
143 | options.filter {
144 | it.contains(
145 | typeText,
146 | ignoreCase = true
147 | )
148 | }
149 | }
150 | }
151 |
152 | var exp by remember { mutableStateOf(false) }
153 |
154 | ExposedDropdownMenuBox(
155 | expanded = exp,
156 | onExpandedChange = { exp = !exp },
157 | modifier = Modifier.weight(0.5f)
158 | ) {
159 | TextField(
160 | value = typeText,
161 | onValueChange = { typeText = it },
162 | label = { Text(stringResource(R.string.format)) },
163 | trailingIcon = {
164 | ExposedDropdownMenuDefaults.TrailingIcon(expanded = exp)
165 | },
166 | colors = ExposedDropdownMenuDefaults.textFieldColors(),
167 | modifier = Modifier.menuAnchor(
168 | ExposedDropdownMenuAnchorType.PrimaryEditable,
169 | true
170 | )
171 | )
172 | // filter options based on text field value (i.e. crude autocomplete)
173 | if (filterOpts.isNotEmpty()) {
174 | ExposedDropdownMenu(expanded = exp, onDismissRequest = { exp = false }) {
175 | filterOpts.forEach { option ->
176 | DropdownMenuItem(
177 | text = { Text(text = option) },
178 | onClick = {
179 | typeText = option
180 | exp = false
181 | }
182 | )
183 | }
184 | }
185 | }
186 | }
187 | }
188 |
189 | // Run button
190 | Button(
191 | onClick = { currentOnRunClicked(codeText.text, valueText.text, typeText) },
192 | modifier = Modifier
193 | .align(Alignment.End)
194 | .padding(top = 8.dp)
195 | ) {
196 | Text(stringResource(R.string.run))
197 | }
198 |
199 | Text(stringResource(R.string.output), style = MaterialTheme.typography.titleMedium)
200 |
201 | // Output text
202 | Card(Modifier.fillMaxWidth()) {
203 | Text(outputText)
204 | }
205 | }
206 | }
207 |
208 | @Preview
209 | @Composable
210 | fun PreviewJavaScriptEditor() {
211 | Surface {
212 | JavaScriptEditor(
213 | initialCode = "",
214 | onRunClicked = { _, _, _ -> },
215 | onEdit = {},
216 | outputText = "Output will appear here"
217 | )
218 | }
219 | }
--------------------------------------------------------------------------------
/app/src/test/java/dev/fabik/bluetoothhid/utils/SerializerTest.kt:
--------------------------------------------------------------------------------
1 | package dev.fabik.bluetoothhid.utils
2 |
3 | import org.json.JSONArray
4 | import org.json.JSONObject
5 | import org.junit.Assert.assertEquals
6 | import org.junit.Assert.assertTrue
7 | import org.junit.Test
8 | import org.junit.runner.RunWith
9 | import org.robolectric.RobolectricTestRunner
10 |
11 | @RunWith(RobolectricTestRunner::class)
12 | class SerializerTest {
13 |
14 | @Test
15 | fun test000_RobolectricWarmup() {
16 | // Warm-up: Initialize Robolectric Android framework shadows
17 | // This runs first (alphabetically) and absorbs the ~3s cold start cost
18 | // Subsequent JSON/XML tests will be fast (~0.03s instead of ~3s)
19 | val warmupJson = JSONObject().put("init", "robolectric")
20 | val warmupArray = JSONArray().put("warmup")
21 |
22 | // Verify initialization worked
23 | assertEquals("robolectric", warmupJson.getString("init"))
24 | assertEquals("warmup", warmupArray.getString(0))
25 | }
26 |
27 | @Test
28 | fun testCsvSerialization() {
29 | // Create test data
30 | val entries = listOf(
31 | Serializer.BarcodeEntry("12345", 1234567890L, "QR_CODE"),
32 | Serializer.BarcodeEntry("67890", 1234567900L, "EAN_13")
33 | )
34 |
35 | // Serialize to CSV
36 | val csv = Serializer.toCsv(entries)
37 |
38 | // Verify CSV contains header and data
39 | assertTrue(csv.contains("\"text\";\"timestamp\";\"format\""))
40 | assertTrue(csv.contains("\"12345\""))
41 | assertTrue(csv.contains("\"QR_CODE\""))
42 | }
43 |
44 | @Test
45 | fun testCsvDeserialization() {
46 | // Modern CSV format
47 | val csv = """
48 | "text";"timestamp";"format"
49 | "12345";"1234567890";"QR_CODE"
50 | "67890";"1234567900";"EAN_13"
51 | """.trimIndent()
52 |
53 | val entries = Serializer.fromCsv(csv)
54 |
55 | assertEquals(2, entries.size)
56 | assertEquals("12345", entries[0].text)
57 | assertEquals(1234567890L, entries[0].timestamp)
58 | assertEquals("QR_CODE", entries[0].format)
59 | }
60 |
61 | @Test
62 | fun testLegacyCsvDeserialization() {
63 | // Legacy CSV format with "type" instead of "format"
64 | val legacyCsv = """
65 | "text";"timestamp";"type"
66 | "12345";"1234567890";"QR_CODE"
67 | "67890";"1234567900";"EAN_13"
68 | """.trimIndent()
69 |
70 | val entries = Serializer.fromCsv(legacyCsv)
71 |
72 | assertEquals(2, entries.size)
73 | assertEquals("12345", entries[0].text)
74 | assertEquals(1234567890L, entries[0].timestamp)
75 | assertEquals("QR_CODE", entries[0].format) // Converted to format
76 | }
77 |
78 | @Test
79 | fun testJsonSerialization() {
80 | val entries = listOf(
81 | Serializer.BarcodeEntry("TEST123", 1000L, "CODE_128")
82 | )
83 |
84 | val json = Serializer.toJson(entries)
85 |
86 | assertTrue(json.contains("barcodes"))
87 | assertTrue(json.contains("barcode"))
88 | assertTrue(json.contains("TEST123"))
89 | assertTrue(json.contains("CODE_128"))
90 | }
91 |
92 | @Test
93 | fun testJsonDeserialization() {
94 | val json = """
95 | {
96 | "barcodes": {
97 | "barcode": [
98 | {
99 | "text": "ABC",
100 | "timestamp": 2000,
101 | "format": "QR_CODE"
102 | }
103 | ]
104 | }
105 | }
106 | """.trimIndent()
107 |
108 | val entries = Serializer.fromJson(json)
109 |
110 | assertEquals(1, entries.size)
111 | assertEquals("ABC", entries[0].text)
112 | assertEquals(2000L, entries[0].timestamp)
113 | assertEquals("QR_CODE", entries[0].format)
114 | }
115 |
116 | @Test
117 | fun testXmlSerialization() {
118 | val entries = listOf(
119 | Serializer.BarcodeEntry("XML_TEST", 3000L, "DATA_MATRIX")
120 | )
121 |
122 | val xml = Serializer.toXml(entries)
123 |
124 | assertTrue(xml.contains(""))
125 | assertTrue(xml.contains(""))
126 | assertTrue(xml.contains(""))
127 | assertTrue(xml.contains("XML_TEST"))
128 | assertTrue(xml.contains("DATA_MATRIX"))
129 | }
130 |
131 | @Test
132 | fun testXmlDeserialization() {
133 | val xml = """
134 |
135 |
136 |
137 | XYZ
138 | 4000
139 | PDF_417
140 |
141 |
142 | """.trimIndent()
143 |
144 | val entries = Serializer.fromXml(xml)
145 |
146 | assertEquals(1, entries.size)
147 | assertEquals("XYZ", entries[0].text)
148 | assertEquals(4000L, entries[0].timestamp)
149 | assertEquals("PDF_417", entries[0].format)
150 | }
151 |
152 | @Test
153 | fun testXmlEscaping() {
154 | // Test special XML characters
155 | val entries = listOf(
156 | Serializer.BarcodeEntry("&\"'value", 5000L, "TYPE")
157 | )
158 |
159 | val xml = Serializer.toXml(entries)
160 | val decoded = Serializer.fromXml(xml)
161 |
162 | assertEquals(1, decoded.size)
163 | assertEquals("&\"'value", decoded[0].text) // Should be preserved
164 | }
165 |
166 | @Test
167 | fun testCsvWithSpecialCharacters() {
168 | // Test CSV with quotes and special chars
169 | val entries = listOf(
170 | Serializer.BarcodeEntry("test\"with\"quotes", 6000L, "TYPE")
171 | )
172 |
173 | val csv = Serializer.toCsv(entries)
174 | val decoded = Serializer.fromCsv(csv)
175 |
176 | assertEquals(1, decoded.size)
177 | assertEquals("test\"with\"quotes", decoded[0].text)
178 | }
179 |
180 | @Test
181 | fun testLinesToPlaintext() {
182 | val entries = listOf(
183 | Serializer.BarcodeEntry("Line1", 1000L, "TYPE1"),
184 | Serializer.BarcodeEntry("Line2", 2000L, "TYPE2")
185 | )
186 |
187 | val lines = Serializer.toLines(entries)
188 |
189 | // Should contain only text values separated by newline
190 | assertTrue(lines.contains("Line1"))
191 | assertTrue(lines.contains("Line2"))
192 | assertEquals(2, lines.split(System.lineSeparator()).size)
193 | }
194 |
195 | @Test
196 | fun testEmptyCsvDeserialization() {
197 | val emptyCsv = ""
198 | val entries = Serializer.fromCsv(emptyCsv)
199 | assertEquals(0, entries.size)
200 | }
201 |
202 | @Test
203 | fun testCsvWithNewlines() {
204 | // Test CSV with newlines in barcode text (e.g., vCard, WiFi config QR codes)
205 | val entries = listOf(
206 | Serializer.BarcodeEntry("Line1\nLine2", 1000L, "QR_CODE"),
207 | Serializer.BarcodeEntry("Windows\r\nStyle", 2000L, "QR_CODE"),
208 | Serializer.BarcodeEntry("Mac\rStyle", 3000L, "DATA_MATRIX")
209 | )
210 |
211 | val csv = Serializer.toCsv(entries)
212 |
213 | // Verify escaping: newlines should be replaced with placeholders
214 | assertTrue(csv.contains("{__CSV_LF__}"))
215 | assertTrue(csv.contains("{__CSV_CR__}{__CSV_LF__}"))
216 | assertTrue(csv.contains("{__CSV_CR__}"))
217 |
218 | // Verify no actual newlines in data rows (only in line separators)
219 | val lines = csv.lines()
220 | assertEquals(4, lines.size) // header + 3 data rows
221 |
222 | // Round-trip test: deserialize should restore original values
223 | val decoded = Serializer.fromCsv(csv)
224 | assertEquals(3, decoded.size)
225 | assertEquals("Line1\nLine2", decoded[0].text)
226 | assertEquals("Windows\r\nStyle", decoded[1].text)
227 | assertEquals("Mac\rStyle", decoded[2].text)
228 | }
229 |
230 | @Test
231 | fun testCsvRoundtripMultiline() {
232 | // Complex multiline barcode (e.g., vCard with multiple fields)
233 | val vcard = "BEGIN:VCARD\nVERSION:3.0\nFN:John Doe\nEND:VCARD"
234 | val entries = listOf(
235 | Serializer.BarcodeEntry(vcard, 12345L, "QR_CODE")
236 | )
237 |
238 | val csv = Serializer.toCsv(entries)
239 | val decoded = Serializer.fromCsv(csv)
240 |
241 | assertEquals(1, decoded.size)
242 | assertEquals(vcard, decoded[0].text)
243 | assertEquals(12345L, decoded[0].timestamp)
244 | assertEquals("QR_CODE", decoded[0].format)
245 | }
246 | }
247 |
--------------------------------------------------------------------------------
/app/src/main/java/dev/fabik/bluetoothhid/ui/SaveScanImageOptions.kt:
--------------------------------------------------------------------------------
1 | package dev.fabik.bluetoothhid.ui
2 |
3 | import android.content.Intent
4 | import android.widget.Toast
5 | import androidx.activity.compose.rememberLauncherForActivityResult
6 | import androidx.activity.result.contract.ActivityResultContracts
7 | import androidx.compose.foundation.layout.Arrangement
8 | import androidx.compose.foundation.layout.Column
9 | import androidx.compose.foundation.layout.Row
10 | import androidx.compose.foundation.layout.Spacer
11 | import androidx.compose.foundation.layout.fillMaxWidth
12 | import androidx.compose.foundation.layout.height
13 | import androidx.compose.foundation.layout.padding
14 | import androidx.compose.foundation.rememberScrollState
15 | import androidx.compose.foundation.text.input.InputTransformation
16 | import androidx.compose.foundation.text.input.TextFieldLineLimits
17 | import androidx.compose.foundation.text.input.byValue
18 | import androidx.compose.foundation.text.input.rememberTextFieldState
19 | import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd
20 | import androidx.compose.foundation.verticalScroll
21 | import androidx.compose.material.icons.Icons
22 | import androidx.compose.material.icons.filled.Folder
23 | import androidx.compose.material.icons.filled.Photo
24 | import androidx.compose.material3.ExperimentalMaterial3Api
25 | import androidx.compose.material3.Icon
26 | import androidx.compose.material3.IconButton
27 | import androidx.compose.material3.MaterialTheme
28 | import androidx.compose.material3.ModalBottomSheet
29 | import androidx.compose.material3.OutlinedTextField
30 | import androidx.compose.material3.Switch
31 | import androidx.compose.material3.Text
32 | import androidx.compose.material3.VerticalDivider
33 | import androidx.compose.material3.rememberModalBottomSheetState
34 | import androidx.compose.runtime.Composable
35 | import androidx.compose.runtime.DisposableEffect
36 | import androidx.compose.runtime.derivedStateOf
37 | import androidx.compose.runtime.getValue
38 | import androidx.compose.runtime.mutableStateOf
39 | import androidx.compose.runtime.remember
40 | import androidx.compose.runtime.saveable.rememberSaveable
41 | import androidx.compose.runtime.setValue
42 | import androidx.compose.ui.Alignment
43 | import androidx.compose.ui.Modifier
44 | import androidx.compose.ui.platform.LocalContext
45 | import androidx.compose.ui.res.stringResource
46 | import androidx.compose.ui.semantics.semantics
47 | import androidx.compose.ui.semantics.stateDescription
48 | import androidx.compose.ui.tooling.preview.Preview
49 | import androidx.compose.ui.unit.dp
50 | import androidx.core.net.toUri
51 | import dev.fabik.bluetoothhid.R
52 | import dev.fabik.bluetoothhid.utils.PreferenceStore
53 | import dev.fabik.bluetoothhid.utils.getPreferenceState
54 | import dev.fabik.bluetoothhid.utils.rememberPreference
55 | import dev.fabik.bluetoothhid.utils.rememberPreferenceNull
56 | import dev.fabik.bluetoothhid.utils.setPreference
57 | import kotlinx.coroutines.runBlocking
58 |
59 | @OptIn(ExperimentalMaterial3Api::class)
60 | @Composable
61 | fun SaveScanImageOptionsModal() {
62 | var saveScanEnabled by rememberPreferenceNull(PreferenceStore.SAVE_SCAN)
63 |
64 | val state = rememberModalBottomSheetState(skipPartiallyExpanded = true)
65 | var showSheet by rememberSaveable { mutableStateOf(false) }
66 |
67 | ButtonPreference(
68 | title = stringResource(R.string.save_scan_image),
69 | desc = stringResource(R.string.save_scan_desc),
70 | icon = Icons.Default.Photo,
71 | onClick = { showSheet = true },
72 | extra = {
73 | Row(verticalAlignment = Alignment.CenterVertically) {
74 | VerticalDivider(
75 | Modifier
76 | .height(32.dp)
77 | .padding(horizontal = 24.dp)
78 | )
79 | saveScanEnabled?.let { c ->
80 | Switch(c, onCheckedChange = {
81 | saveScanEnabled = it
82 | }, modifier = Modifier.semantics(mergeDescendants = true) {
83 | stateDescription = "Save scan image is ${if (c) "On" else "Off"}"
84 | })
85 | }
86 | }
87 | }
88 | )
89 |
90 | if (showSheet) {
91 | ModalBottomSheet(
92 | sheetState = state,
93 | onDismissRequest = { showSheet = false },
94 | content = {
95 | SaveToImageOptionsContent()
96 | }
97 | )
98 | }
99 | }
100 |
101 | @OptIn(ExperimentalMaterial3Api::class)
102 | @Composable
103 | @Preview(showBackground = true)
104 | fun SaveToImageOptionsContent() {
105 | Column(
106 | verticalArrangement = Arrangement.spacedBy(4.dp),
107 | modifier = Modifier
108 | .verticalScroll(rememberScrollState())
109 | .fillMaxWidth()
110 | .padding(horizontal = 16.dp)
111 | ) {
112 | Text(
113 | stringResource(R.string.save_scan_image),
114 | style = MaterialTheme.typography.titleLarge,
115 | )
116 |
117 | FolderPicker()
118 | AdvancedEnumSelectionOption(
119 | stringResource(R.string.crop_mode),
120 | arrayOf("NONE", "SCAN_AREA", "BARCODE"),
121 | PreferenceStore.SAVE_SCAN_CROP_MODE
122 | )
123 | AdvancedSliderOption(
124 | stringResource(R.string.image_quality),
125 | 1 to 100,
126 | PreferenceStore.SAVE_SCAN_QUALITY
127 | )
128 |
129 | val context = LocalContext.current
130 | val storedFileName by context.getPreferenceState(PreferenceStore.SAVE_SCAN_FILE_PATTERN)
131 | val localFileName = rememberTextFieldState()
132 |
133 | storedFileName?.let {
134 | DisposableEffect(it) {
135 | localFileName.setTextAndPlaceCursorAtEnd(it)
136 | onDispose {
137 | runBlocking {
138 | context.setPreference(
139 | PreferenceStore.SAVE_SCAN_FILE_PATTERN,
140 | localFileName.text.toString()
141 | )
142 | }
143 | }
144 | }
145 | }
146 |
147 | OutlinedTextField(
148 | state = localFileName,
149 | supportingText = { Text(stringResource(R.string.file_name_pattern_placeholder)) },
150 | label = { Text(stringResource(R.string.file_name_pattern)) },
151 | inputTransformation = InputTransformation.byValue { current, proposed ->
152 | if (proposed.contains("/")) {
153 | current
154 | } else {
155 | proposed
156 | }
157 | },
158 | lineLimits = TextFieldLineLimits.SingleLine,
159 | modifier = Modifier
160 | .fillMaxWidth()
161 | .padding(2.dp),
162 | )
163 |
164 | Text(
165 | stringResource(R.string.save_scan_image_hint),
166 | style = MaterialTheme.typography.bodyMedium
167 | )
168 |
169 | Spacer(Modifier.height(12.dp))
170 | }
171 | }
172 |
173 | @Composable
174 | fun FolderPicker() {
175 | val context = LocalContext.current
176 |
177 | var scanPath by rememberPreference(PreferenceStore.SAVE_SCAN_PATH)
178 | val currentUri by remember { derivedStateOf { if (scanPath.isNotBlank()) scanPath.toUri() else null } }
179 |
180 | val folderPickerLauncher = rememberLauncherForActivityResult(
181 | contract = ActivityResultContracts.OpenDocumentTree()
182 | ) { uri ->
183 | uri?.let {
184 | val contentResolver = context.contentResolver
185 | val takeFlags = Intent.FLAG_GRANT_READ_URI_PERMISSION or
186 | Intent.FLAG_GRANT_WRITE_URI_PERMISSION
187 |
188 | currentUri?.let { prevUri ->
189 | contentResolver.releasePersistableUriPermission(prevUri, takeFlags)
190 | }
191 |
192 | // Persist the permission
193 | contentResolver.takePersistableUriPermission(it, takeFlags)
194 | scanPath = it.toString()
195 | }
196 | }
197 |
198 | Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
199 | OutlinedTextField(
200 | currentUri?.toString() ?: "No path selected",
201 | onValueChange = {},
202 | readOnly = true,
203 | label = { Text(stringResource(R.string.output_folder)) },
204 | trailingIcon = {
205 | IconButton(onClick = {
206 | runCatching {
207 | folderPickerLauncher.launch(currentUri)
208 | }.onFailure {
209 | Toast.makeText(context, it.message, Toast.LENGTH_LONG).show()
210 | }
211 | }) {
212 | Icon(Icons.Default.Folder, null)
213 | }
214 | },
215 | modifier = Modifier
216 | .fillMaxWidth()
217 | .padding(2.dp)
218 | )
219 | }
220 | }
221 |
--------------------------------------------------------------------------------
/app/src/main/java/dev/fabik/bluetoothhid/ui/AdvancedScannerOptions.kt:
--------------------------------------------------------------------------------
1 | package dev.fabik.bluetoothhid.ui
2 |
3 | import androidx.compose.foundation.layout.Column
4 | import androidx.compose.foundation.layout.ExperimentalLayoutApi
5 | import androidx.compose.foundation.layout.Row
6 | import androidx.compose.foundation.layout.Spacer
7 | import androidx.compose.foundation.layout.fillMaxWidth
8 | import androidx.compose.foundation.layout.height
9 | import androidx.compose.foundation.layout.padding
10 | import androidx.compose.foundation.rememberScrollState
11 | import androidx.compose.foundation.selection.toggleable
12 | import androidx.compose.foundation.verticalScroll
13 | import androidx.compose.material.icons.Icons
14 | import androidx.compose.material.icons.filled.Science
15 | import androidx.compose.material3.DropdownMenuItem
16 | import androidx.compose.material3.ExperimentalMaterial3Api
17 | import androidx.compose.material3.ExposedDropdownMenuAnchorType
18 | import androidx.compose.material3.ExposedDropdownMenuBox
19 | import androidx.compose.material3.ExposedDropdownMenuDefaults
20 | import androidx.compose.material3.MaterialTheme
21 | import androidx.compose.material3.ModalBottomSheet
22 | import androidx.compose.material3.OutlinedTextField
23 | import androidx.compose.material3.Slider
24 | import androidx.compose.material3.Switch
25 | import androidx.compose.material3.Text
26 | import androidx.compose.material3.rememberModalBottomSheetState
27 | import androidx.compose.runtime.Composable
28 | import androidx.compose.runtime.getValue
29 | import androidx.compose.runtime.mutableIntStateOf
30 | import androidx.compose.runtime.mutableStateOf
31 | import androidx.compose.runtime.remember
32 | import androidx.compose.runtime.saveable.rememberSaveable
33 | import androidx.compose.runtime.setValue
34 | import androidx.compose.ui.Alignment
35 | import androidx.compose.ui.Modifier
36 | import androidx.compose.ui.res.stringResource
37 | import androidx.compose.ui.semantics.semantics
38 | import androidx.compose.ui.semantics.stateDescription
39 | import androidx.compose.ui.tooling.preview.Preview
40 | import androidx.compose.ui.unit.dp
41 | import dev.fabik.bluetoothhid.R
42 | import dev.fabik.bluetoothhid.utils.PreferenceStore
43 | import dev.fabik.bluetoothhid.utils.rememberEnumPreference
44 | import dev.fabik.bluetoothhid.utils.rememberPreference
45 | import kotlin.math.roundToInt
46 |
47 | @OptIn(ExperimentalMaterial3Api::class)
48 | @Composable
49 | fun AdvancedOptionsModal() {
50 | val state = rememberModalBottomSheetState(skipPartiallyExpanded = true)
51 | var showSheet by rememberSaveable { mutableStateOf(false) }
52 |
53 | ButtonPreference(
54 | title = stringResource(R.string.advanced_options),
55 | desc = stringResource(R.string.advanced_opts_desc),
56 | icon = Icons.Default.Science,
57 | onClick = { showSheet = true }
58 | )
59 |
60 | if (showSheet) {
61 | ModalBottomSheet(
62 | sheetState = state,
63 | onDismissRequest = { showSheet = false },
64 | content = {
65 | AdvancedOptionsModalContent()
66 | }
67 | )
68 | }
69 | }
70 |
71 | @OptIn(ExperimentalLayoutApi::class)
72 | @Composable
73 | fun AdvancedOptionsModalContent() {
74 | Column(
75 | modifier = Modifier
76 | .verticalScroll(rememberScrollState())
77 | .fillMaxWidth()
78 | .padding(horizontal = 16.dp)
79 | ) {
80 | Text(
81 | stringResource(R.string.advanced_options),
82 | style = MaterialTheme.typography.titleLarge,
83 | )
84 |
85 | Spacer(Modifier.height(4.dp))
86 |
87 | Text(
88 | stringResource(R.string.detection),
89 | color = MaterialTheme.colorScheme.primary,
90 | style = MaterialTheme.typography.titleSmall
91 | )
92 |
93 | AdvancedToggleOption(stringResource(R.string.try_harder), PreferenceStore.ADV_TRY_HARDER)
94 | AdvancedToggleOption(
95 | stringResource(R.string.try_rotate_image),
96 | PreferenceStore.ADV_TRY_ROTATE
97 | )
98 | AdvancedToggleOption(stringResource(R.string.try_inverted), PreferenceStore.ADV_TRY_INVERT)
99 | AdvancedToggleOption(
100 | stringResource(R.string.try_downscale),
101 | PreferenceStore.ADV_TRY_DOWNSCALE
102 | )
103 | AdvancedSliderOption(
104 | stringResource(R.string.minimum_scan_lines),
105 | 1 to 50,
106 | PreferenceStore.ADV_MIN_LINE_COUNT
107 | )
108 |
109 | Text(
110 | stringResource(R.string.processing),
111 | color = MaterialTheme.colorScheme.primary,
112 | style = MaterialTheme.typography.titleSmall
113 | )
114 |
115 | AdvancedEnumSelectionOption(
116 | stringResource(R.string.binarizer),
117 | arrayOf("LOCAL_AVERAGE", "GLOBAL_HISTOGRAM", "FIXED_THRESHOLD", "BOOL_CAST"),
118 | PreferenceStore.ADV_BINARIZER
119 | )
120 | AdvancedSliderOption(
121 | stringResource(R.string.downscale_factor),
122 | 1 to 10,
123 | PreferenceStore.ADV_DOWNSCALE_FACTOR
124 | )
125 | AdvancedSliderOption(
126 | stringResource(R.string.downscale_threshold),
127 | 0 to 1000,
128 | PreferenceStore.ADV_DOWNSCALE_THRESHOLD
129 | )
130 |
131 | Text(
132 | stringResource(R.string.parser),
133 | color = MaterialTheme.colorScheme.primary,
134 | style = MaterialTheme.typography.titleSmall
135 | )
136 |
137 | AdvancedEnumSelectionOption(
138 | stringResource(R.string.text_mode),
139 | arrayOf("PLAIN", "ECI", "HRI", "HEX", "ESCAPED"),
140 | PreferenceStore.ADV_TEXT_MODE
141 | )
142 |
143 | Spacer(Modifier.height(16.dp))
144 | }
145 | }
146 |
147 | @Composable
148 | fun AdvancedToggleOption(text: String, preference: PreferenceStore.Preference) {
149 | var checked by rememberPreference(preference)
150 |
151 | Row(
152 | Modifier
153 | .toggleable(checked, onValueChange = { checked = it })
154 | .padding(2.dp),
155 | verticalAlignment = Alignment.CenterVertically
156 | ) {
157 | Text(text, Modifier.weight(1.0f))
158 | Switch(
159 | checked,
160 | onCheckedChange = null,
161 | modifier = Modifier.semantics(mergeDescendants = true) {
162 | stateDescription = "$text is ${if (checked) "On" else "Off"}"
163 | })
164 | }
165 | }
166 |
167 | @OptIn(ExperimentalMaterial3Api::class)
168 | @Composable
169 | fun > AdvancedEnumSelectionOption(
170 | text: String,
171 | values: Array,
172 | preference: PreferenceStore.EnumPref
173 | ) {
174 | var expanded by rememberSaveable { mutableStateOf(false) }
175 | var selectedEnum by rememberEnumPreference(preference)
176 |
177 | ExposedDropdownMenuBox(
178 | expanded = expanded,
179 | onExpandedChange = {
180 | expanded = !expanded
181 | }
182 | ) {
183 | OutlinedTextField(
184 | readOnly = true,
185 | singleLine = true,
186 | value = values.getOrNull(selectedEnum.ordinal) ?: "",
187 | onValueChange = { },
188 | label = { Text(text) },
189 | trailingIcon = {
190 | ExposedDropdownMenuDefaults.TrailingIcon(
191 | expanded = expanded
192 | )
193 | },
194 | colors = ExposedDropdownMenuDefaults.textFieldColors(),
195 | modifier = Modifier
196 | .menuAnchor(ExposedDropdownMenuAnchorType.PrimaryNotEditable)
197 | .fillMaxWidth()
198 | .padding(2.dp)
199 | )
200 | ExposedDropdownMenu(
201 | expanded = expanded,
202 | onDismissRequest = {
203 | expanded = false
204 | }
205 | ) {
206 | values.forEachIndexed { i, selectionOption ->
207 | DropdownMenuItem(
208 | text = { Text(text = selectionOption) },
209 | onClick = {
210 | selectedEnum = preference.fromOrdinal(i)
211 | expanded = false
212 | }
213 | )
214 | }
215 | }
216 | }
217 | }
218 |
219 | @Composable
220 | fun AdvancedSliderOption(
221 | text: String,
222 | range: Pair,
223 | preference: PreferenceStore.Preference
224 | ) {
225 | var value by rememberPreference(preference)
226 | var currentValue by remember { mutableIntStateOf(value) }
227 |
228 | Column(Modifier.padding(2.dp)) {
229 | Text("$text: $currentValue")
230 | Slider(
231 | value = currentValue.toFloat(),
232 | onValueChange = { currentValue = it.roundToInt() },
233 | onValueChangeFinished = { value = currentValue },
234 | valueRange = range.first.toFloat()..range.second.toFloat()
235 | )
236 | }
237 | }
238 |
239 | @Preview(showBackground = true)
240 | @Composable
241 | fun AdvancedOptionsPreview() {
242 | AdvancedOptionsModalContent()
243 | }
--------------------------------------------------------------------------------
/app/src/main/java/dev/fabik/bluetoothhid/ui/HistoryFilter.kt:
--------------------------------------------------------------------------------
1 | package dev.fabik.bluetoothhid.ui
2 |
3 | import androidx.compose.foundation.layout.Arrangement
4 | import androidx.compose.foundation.layout.Column
5 | import androidx.compose.foundation.layout.ExperimentalLayoutApi
6 | import androidx.compose.foundation.layout.FlowRow
7 | import androidx.compose.foundation.layout.Row
8 | import androidx.compose.foundation.layout.Spacer
9 | import androidx.compose.foundation.layout.fillMaxWidth
10 | import androidx.compose.foundation.layout.height
11 | import androidx.compose.foundation.layout.padding
12 | import androidx.compose.material.icons.Icons
13 | import androidx.compose.material.icons.filled.DateRange
14 | import androidx.compose.material.icons.filled.FilterAlt
15 | import androidx.compose.material3.Badge
16 | import androidx.compose.material3.BadgedBox
17 | import androidx.compose.material3.Button
18 | import androidx.compose.material3.DatePickerDialog
19 | import androidx.compose.material3.DateRangePicker
20 | import androidx.compose.material3.ExperimentalMaterial3Api
21 | import androidx.compose.material3.FilterChip
22 | import androidx.compose.material3.Icon
23 | import androidx.compose.material3.IconButton
24 | import androidx.compose.material3.MaterialTheme
25 | import androidx.compose.material3.ModalBottomSheet
26 | import androidx.compose.material3.OutlinedButton
27 | import androidx.compose.material3.OutlinedTextField
28 | import androidx.compose.material3.Text
29 | import androidx.compose.material3.TextButton
30 | import androidx.compose.material3.rememberDateRangePickerState
31 | import androidx.compose.material3.rememberModalBottomSheetState
32 | import androidx.compose.runtime.Composable
33 | import androidx.compose.runtime.getValue
34 | import androidx.compose.runtime.mutableStateOf
35 | import androidx.compose.runtime.rememberCoroutineScope
36 | import androidx.compose.runtime.saveable.listSaver
37 | import androidx.compose.runtime.saveable.rememberSaveable
38 | import androidx.compose.runtime.setValue
39 | import androidx.compose.runtime.toMutableStateList
40 | import androidx.compose.ui.Modifier
41 | import androidx.compose.ui.res.stringArrayResource
42 | import androidx.compose.ui.res.stringResource
43 | import androidx.compose.ui.tooling.preview.Preview
44 | import androidx.compose.ui.unit.dp
45 | import dev.fabik.bluetoothhid.R
46 | import kotlinx.coroutines.launch
47 | import java.time.Instant
48 | import java.time.ZoneId
49 | import java.time.format.DateTimeFormatter
50 | import java.time.format.FormatStyle
51 | import java.util.Locale
52 |
53 | @OptIn(ExperimentalMaterial3Api::class)
54 | @Composable
55 | fun FilterModal(
56 | selectedTypes: Set,
57 | startDate: Long?,
58 | endDate: Long?,
59 | onApply: (Set, Long?, Long?) -> Unit
60 | ) {
61 | val scope = rememberCoroutineScope()
62 | val state = rememberModalBottomSheetState(skipPartiallyExpanded = true)
63 | var showSheet by rememberSaveable { mutableStateOf(false) }
64 |
65 | IconButton(onClick = { showSheet = true }, Modifier.tooltip(stringResource(R.string.filter))) {
66 | BadgedBox(badge = {
67 | if (selectedTypes.isNotEmpty() || startDate != null || endDate != null)
68 | Badge()
69 | }) {
70 | Icon(Icons.Default.FilterAlt, "Open Filter")
71 | }
72 | }
73 |
74 | if (showSheet) {
75 | ModalBottomSheet(
76 | sheetState = state,
77 | onDismissRequest = { showSheet = false },
78 | content = {
79 | FilterModalContent(
80 | selectedTypes, startDate, endDate,
81 | onApply = { sel, a, b ->
82 | scope.launch { state.hide() }
83 | .invokeOnCompletion { showSheet = state.isVisible }
84 | onApply(sel, a, b)
85 | }
86 | )
87 | }
88 | )
89 | }
90 | }
91 |
92 | @OptIn(ExperimentalLayoutApi::class)
93 | @Composable
94 | fun FilterModalContent(
95 | selectedTypes: Set,
96 | startDate: Long?,
97 | endDate: Long?,
98 | onApply: (Set, Long?, Long?) -> Unit
99 | ) {
100 | var selectedTypes =
101 | rememberSaveable(saver = listSaver({ it.toList() }, { it.toMutableStateList() })) {
102 | selectedTypes.toMutableStateList()
103 | }
104 |
105 | var selectedDateStart by rememberSaveable { mutableStateOf(startDate) }
106 | var selectedDateEnd by rememberSaveable { mutableStateOf(endDate) }
107 | var showDateModal by rememberSaveable { mutableStateOf(false) }
108 |
109 | if (showDateModal) {
110 | DateRangePickerModal(
111 | onDateRangeSelected = { (start, end) ->
112 | selectedDateStart = start
113 | selectedDateEnd = end
114 | },
115 | onDismiss = { showDateModal = false }
116 | )
117 | }
118 |
119 | Column(
120 | modifier = Modifier
121 | .fillMaxWidth()
122 | .padding(horizontal = 16.dp)
123 | ) {
124 | Text(
125 | stringResource(R.string.filter),
126 | style = MaterialTheme.typography.titleLarge,
127 | )
128 |
129 | Spacer(modifier = Modifier.height(16.dp))
130 |
131 | OutlinedTextField(
132 | value = convertMillisToDate(selectedDateStart) + " - " + convertMillisToDate(
133 | selectedDateEnd
134 | ),
135 | onValueChange = { },
136 | readOnly = true,
137 | label = { Text(stringResource(R.string.date_range)) },
138 | trailingIcon = {
139 | IconButton(onClick = { showDateModal = !showDateModal }) {
140 | Icon(Icons.Default.DateRange, "Select date")
141 | }
142 | },
143 | modifier = Modifier
144 | .fillMaxWidth()
145 | .padding(4.dp)
146 | )
147 |
148 | Spacer(modifier = Modifier.height(16.dp))
149 |
150 | // Type Filter with Chips
151 | Text(stringResource(R.string.select_types))
152 |
153 | FlowRow(
154 | horizontalArrangement = Arrangement.SpaceEvenly,
155 | modifier = Modifier.fillMaxWidth()
156 | ) {
157 | stringArrayResource(R.array.code_types_values).forEachIndexed { i, type ->
158 | FilterChip(
159 | selected = selectedTypes.contains(i),
160 | onClick = {
161 | if (selectedTypes.contains(i)) {
162 | selectedTypes.remove(i)
163 | } else {
164 | selectedTypes.add(i)
165 | }
166 | },
167 | label = { Text(type) },
168 | Modifier.padding(horizontal = 2.dp)
169 | )
170 | }
171 | }
172 |
173 | Spacer(modifier = Modifier.height(16.dp))
174 |
175 | Row(
176 | modifier = Modifier.fillMaxWidth(),
177 | horizontalArrangement = Arrangement.SpaceEvenly
178 | ) {
179 | OutlinedButton(onClick = {
180 | selectedDateStart = null
181 | selectedDateEnd = null
182 | selectedTypes.clear()
183 | }) {
184 | Text(stringResource(R.string.clear))
185 | }
186 |
187 | Button(onClick = {
188 | onApply(
189 | selectedTypes.toSet(),
190 | selectedDateStart,
191 | selectedDateEnd
192 | )
193 | }) {
194 | Text(stringResource(R.string.apply))
195 | }
196 | }
197 |
198 | Spacer(Modifier.height(16.dp))
199 | }
200 | }
201 |
202 | @OptIn(ExperimentalMaterial3Api::class)
203 | @Composable
204 | fun DateRangePickerModal(
205 | onDateRangeSelected: (Pair) -> Unit,
206 | onDismiss: () -> Unit
207 | ) {
208 | val dateRangePickerState = rememberDateRangePickerState()
209 |
210 | DatePickerDialog(
211 | onDismissRequest = onDismiss,
212 | confirmButton = {
213 | TextButton(
214 | onClick = {
215 | onDateRangeSelected(
216 | dateRangePickerState.selectedStartDateMillis to dateRangePickerState.selectedEndDateMillis
217 | )
218 | onDismiss()
219 | }
220 | ) {
221 | Text(stringResource(android.R.string.ok))
222 | }
223 | },
224 | dismissButton = {
225 | TextButton(onClick = onDismiss) {
226 | Text(stringResource(android.R.string.cancel))
227 | }
228 | }
229 | ) {
230 | DateRangePicker(dateRangePickerState)
231 | }
232 | }
233 |
234 | fun convertMillisToDate(millis: Long?): String {
235 | if (millis == null) {
236 | return ""
237 | }
238 |
239 | val formatter = DateTimeFormatter.ofLocalizedDate(FormatStyle.SHORT)
240 | .withLocale(Locale.getDefault()).withZone(ZoneId.systemDefault())
241 | val instant = Instant.ofEpochMilli(millis)
242 | return formatter.format(instant)
243 | }
244 |
245 | @Preview(showBackground = true)
246 | @Composable
247 | fun DefaultPreview() {
248 | FilterModalContent(setOf(), null, null) { sel, a, b -> }
249 | }
--------------------------------------------------------------------------------
/app/src/main/java/dev/fabik/bluetoothhid/ui/model/HistoryViewModel.kt:
--------------------------------------------------------------------------------
1 | package dev.fabik.bluetoothhid.ui.model
2 |
3 | import android.content.Context
4 | import android.util.Log
5 | import androidx.annotation.StringRes
6 | import androidx.compose.material.icons.Icons
7 | import androidx.compose.material.icons.filled.Code
8 | import androidx.compose.material.icons.filled.DataObject
9 | import androidx.compose.material.icons.filled.TableRows
10 | import androidx.compose.material.icons.filled.TableView
11 | import androidx.compose.runtime.derivedStateOf
12 | import androidx.compose.runtime.getValue
13 | import androidx.compose.runtime.mutableStateListOf
14 | import androidx.compose.runtime.mutableStateOf
15 | import androidx.compose.runtime.setValue
16 | import androidx.compose.runtime.snapshots.SnapshotStateList
17 | import androidx.compose.ui.graphics.vector.ImageVector
18 | import androidx.compose.ui.util.fastDistinctBy
19 | import androidx.compose.ui.util.fastFilter
20 | import androidx.lifecycle.ViewModel
21 | import dev.fabik.bluetoothhid.R
22 | import dev.fabik.bluetoothhid.utils.Serializer
23 |
24 | class HistoryViewModel : ViewModel() {
25 | private var selectedHistory: SnapshotStateList = mutableStateListOf()
26 |
27 | val selectionSize by derivedStateOf { selectedHistory.size }
28 | val isSelecting by derivedStateOf { selectedHistory.isNotEmpty() }
29 |
30 | var isSearching by mutableStateOf(false)
31 | var searchQuery by mutableStateOf("")
32 |
33 | var filteredTypes = mutableStateListOf()
34 | var filterDateStart by mutableStateOf(null)
35 | var filterDateEnd by mutableStateOf(null)
36 |
37 | val filteredHistory by derivedStateOf {
38 | historyEntries.fastFilter { (barcode, timestamp, type) ->
39 | barcode.contains(searchQuery, ignoreCase = true)
40 | && (filteredTypes.isEmpty() || filteredTypes.contains(type))
41 | && (filterDateStart == null || timestamp > filterDateStart!!)
42 | && (filterDateEnd == null || timestamp < filterDateEnd!!)
43 | }
44 | }
45 |
46 | companion object {
47 | private const val TAG = "History"
48 |
49 | private const val HISTORY_FILE = "history.csv"
50 | private var historyFileLoaded = false
51 |
52 | var historyEntries: SnapshotStateList = mutableStateListOf()
53 |
54 | fun saveHistory(context: Context) {
55 | runCatching {
56 | val file = context.filesDir.resolve(HISTORY_FILE)
57 |
58 | // Cleanup file if history empty
59 | if (historyEntries.isEmpty()) {
60 | context.deleteFile(file.name)
61 | return
62 | }
63 |
64 | Log.d(TAG, "Saving history to: $file")
65 |
66 | file.bufferedWriter().use {
67 | // Internal format: numeric format for efficiency
68 | val entries = historyEntries.map {
69 | Serializer.BarcodeEntry(it.value, it.timestamp, it.format.toString())
70 | }
71 | it.write(Serializer.toCsv(entries))
72 | }
73 | }.onFailure {
74 | Log.e(TAG, "Failed to store history:", it)
75 | }
76 | }
77 |
78 | fun restoreHistory(context: Context) {
79 | // guard for only loading once
80 | if (historyFileLoaded) {
81 | return
82 | }
83 |
84 | historyFileLoaded = true
85 |
86 | runCatching {
87 | val file = context.filesDir.resolve(HISTORY_FILE)
88 |
89 | if (!file.exists()) {
90 | Log.d(TAG, "No history file exists: $file")
91 | return
92 | }
93 |
94 | Log.d(TAG, "Loading history from: $file")
95 |
96 | val csvContent = file.readText()
97 | val parsedEntries = Serializer.fromCsv(csvContent)
98 |
99 | parsedEntries.forEach { entry ->
100 | val formatIndex = entry.format.toIntOrNull() ?: -1
101 | historyEntries.add(HistoryEntry(entry.text, entry.timestamp, formatIndex))
102 | }
103 | }.onFailure {
104 | Log.e(TAG, "Error loading history:", it)
105 | }
106 | }
107 |
108 | fun addHistoryItem(value: String, format: Int) {
109 | val currentTime = System.currentTimeMillis()
110 | historyEntries.add(HistoryEntry(value, currentTime, format))
111 | }
112 |
113 | fun clearHistory() {
114 | historyEntries.clear()
115 | }
116 |
117 | fun exportEntries(
118 | dataToExport: List,
119 | exportType: ExportType,
120 | formatNames: Array
121 | ): String {
122 | val entries = dataToExport.map { entry ->
123 | Serializer.BarcodeEntry(
124 | text = entry.value,
125 | timestamp = entry.timestamp,
126 | format = formatNames.getOrNull(entry.format) ?: "UNKNOWN"
127 | )
128 | }
129 |
130 | return when (exportType) {
131 | ExportType.LINES -> Serializer.toLines(entries)
132 | ExportType.CSV -> Serializer.toCsv(entries)
133 | ExportType.JSON -> Serializer.toJson(entries)
134 | ExportType.XML -> Serializer.toXml(entries)
135 | }
136 | }
137 |
138 | /**
139 | * Imports barcode entries from various formats.
140 | *
141 | * @param content File content to parse
142 | * @param format Import format (CSV, JSON, or XML)
143 | * @param formatNames Array mapping format names to indices
144 | * @return Number of successfully imported entries
145 | */
146 | fun importHistory(
147 | content: String,
148 | format: ImportFormat,
149 | formatNames: Array
150 | ): Int {
151 | val parsedEntries = when (format) {
152 | ImportFormat.CSV -> Serializer.fromCsv(content)
153 | ImportFormat.JSON -> Serializer.fromJson(content)
154 | ImportFormat.XML -> Serializer.fromXml(content)
155 | }
156 |
157 | // Convert Serializer.BarcodeEntry to HistoryEntry
158 | parsedEntries.forEach { entry ->
159 | // Parse format: either numeric string or name string
160 | val formatIndex = entry.format.toIntOrNull()
161 | ?: formatNames.indexOf(entry.format).takeIf { it >= 0 }
162 | ?: -1
163 |
164 | historyEntries.add(HistoryEntry(entry.text, entry.timestamp, formatIndex))
165 | }
166 |
167 | return parsedEntries.size
168 | }
169 | }
170 |
171 | fun deleteSelectedItems() {
172 | historyEntries.removeIf {
173 | selectedHistory.contains(it.hashCode())
174 | }
175 | clearSelection()
176 | }
177 |
178 | fun clearSelection() {
179 | selectedHistory.clear()
180 | }
181 |
182 | fun isItemSelected(item: HistoryEntry): Boolean {
183 | return selectedHistory.contains(item.hashCode())
184 | }
185 |
186 | fun setItemSelected(item: HistoryEntry, selected: Boolean) {
187 | if (selected) {
188 | selectedHistory.add(item.hashCode())
189 | } else {
190 | selectedHistory.remove(item.hashCode())
191 | }
192 | }
193 |
194 | fun exportHistory(
195 | exportType: ExportType,
196 | deduplicate: Boolean,
197 | formatNames: Array
198 | ): String {
199 | var history = filteredHistory
200 | if (isSelecting) {
201 | history = history.fastFilter {
202 | selectedHistory.contains(it.hashCode())
203 | }
204 | }
205 | if (deduplicate) {
206 | history = history.fastDistinctBy { it.value }
207 | }
208 | return exportEntries(history, exportType, formatNames)
209 | }
210 |
211 | data class HistoryEntry(val value: String, val timestamp: Long, val format: Int)
212 |
213 | enum class ExportType(
214 | @StringRes val label: Int,
215 | @StringRes val description: Int,
216 | val icon: ImageVector
217 | ) {
218 | CSV(R.string.export_csv, R.string.export_fields, Icons.Default.TableView),
219 | JSON(R.string.export_json, R.string.export_fields, Icons.Default.DataObject),
220 | XML(R.string.export_xml, R.string.export_fields, Icons.Filled.Code),
221 | LINES(R.string.export_lines, R.string.export_lines_description, Icons.Default.TableRows)
222 | }
223 |
224 | enum class ImportFormat(
225 | @StringRes val label: Int,
226 | @StringRes val description: Int,
227 | val icon: ImageVector,
228 | val baseMime: String, // was: mimeType: String
229 | val extraMimeTypes: Array? = null
230 | ) {
231 | CSV(
232 | R.string.export_csv,
233 | R.string.export_fields,
234 | Icons.Default.TableView,
235 | baseMime = "*/*",
236 | extraMimeTypes = arrayOf(
237 | "text/csv",
238 | "application/csv",
239 | "text/comma-separated-values",
240 | "application/vnd.ms-excel",
241 | "application/vnd.msexcel"
242 | )
243 | ),
244 | JSON(
245 | R.string.export_json,
246 | R.string.export_fields,
247 | Icons.Default.DataObject,
248 | baseMime = "application/json"
249 | ),
250 | XML(
251 | R.string.export_xml,
252 | R.string.export_fields,
253 | Icons.Filled.Code,
254 | baseMime = "text/xml",
255 | extraMimeTypes = arrayOf(
256 | "text/xml",
257 | "application/xml"
258 | )
259 | )
260 | }
261 | }
--------------------------------------------------------------------------------
/app/src/main/java/dev/fabik/bluetoothhid/ui/Dropdown.kt:
--------------------------------------------------------------------------------
1 | package dev.fabik.bluetoothhid.ui
2 |
3 | import android.content.Intent
4 | import androidx.activity.compose.LocalActivity
5 | import androidx.compose.foundation.layout.Box
6 | import androidx.compose.foundation.layout.Column
7 | import androidx.compose.foundation.layout.fillMaxWidth
8 | import androidx.compose.foundation.layout.widthIn
9 | import androidx.compose.material.icons.Icons
10 | import androidx.compose.material.icons.filled.Close
11 | import androidx.compose.material.icons.filled.DataObject
12 | import androidx.compose.material.icons.filled.Info
13 | import androidx.compose.material.icons.filled.MoreVert
14 | import androidx.compose.material.icons.filled.Shield
15 | import androidx.compose.material.icons.outlined.Settings
16 | import androidx.compose.material3.Checkbox
17 | import androidx.compose.material3.DropdownMenu
18 | import androidx.compose.material3.DropdownMenuItem
19 | import androidx.compose.material3.Icon
20 | import androidx.compose.material3.IconButton
21 | import androidx.compose.material3.MaterialTheme
22 | import androidx.compose.material3.OutlinedButton
23 | import androidx.compose.material3.Text
24 | import androidx.compose.runtime.Composable
25 | import androidx.compose.runtime.getValue
26 | import androidx.compose.runtime.mutableStateOf
27 | import androidx.compose.runtime.saveable.rememberSaveable
28 | import androidx.compose.runtime.setValue
29 | import androidx.compose.ui.Alignment
30 | import androidx.compose.ui.Modifier
31 | import androidx.compose.ui.draw.drawBehind
32 | import androidx.compose.ui.graphics.Color
33 | import androidx.compose.ui.platform.LocalContext
34 | import androidx.compose.ui.platform.LocalUriHandler
35 | import androidx.compose.ui.res.stringResource
36 | import androidx.compose.ui.unit.dp
37 | import dev.fabik.bluetoothhid.R
38 | import dev.fabik.bluetoothhid.SettingsActivity
39 | import dev.fabik.bluetoothhid.bt.BluetoothService
40 | import dev.fabik.bluetoothhid.utils.ConnectionMode
41 | import dev.fabik.bluetoothhid.utils.PreferenceStore
42 | import dev.fabik.bluetoothhid.utils.getPreferenceState
43 | import dev.fabik.bluetoothhid.utils.rememberPreference
44 |
45 | @Composable
46 | fun Dropdown(transparent: Boolean = false) {
47 | val context = LocalContext.current
48 | val activity = LocalActivity.current
49 |
50 | var showMenu by rememberSaveable {
51 | mutableStateOf(false)
52 | }
53 |
54 | val developerMode by context.getPreferenceState(PreferenceStore.DEVELOPER_MODE)
55 |
56 | Box {
57 | IconButton(
58 | onClick = { showMenu = !showMenu },
59 | modifier = Modifier.tooltip(stringResource(R.string.more))
60 | ) {
61 | Icon(
62 | Icons.Default.MoreVert,
63 | "More options",
64 | tint = if (transparent) Color.White else MaterialTheme.colorScheme.onSurface,
65 | modifier = if (transparent) {
66 | Modifier.drawBehind {
67 | // Draw shadow behind icon for better visibility
68 | drawCircle(
69 | color = Color.Black.copy(alpha = 0.5f),
70 | radius = size.maxDimension
71 | )
72 | }
73 | } else Modifier
74 | )
75 | }
76 |
77 | DropdownMenu(
78 | expanded = showMenu,
79 | modifier = Modifier.widthIn(min = 150.dp),
80 | onDismissRequest = { showMenu = false }) {
81 | if (developerMode == true) {
82 | DropdownMenuItem(
83 | text = { Text(stringResource(R.string.refresh_proxy)) },
84 | onClick = {
85 | showMenu = false
86 | context.startForegroundService(
87 | Intent(
88 | context,
89 | BluetoothService::class.java
90 | ).apply {
91 | action = BluetoothService.ACTION_REGISTER
92 | }
93 | )
94 | }
95 | )
96 | DropdownMenuItem(
97 | text = { Text(stringResource(R.string.stop_proxy)) },
98 | onClick = {
99 | showMenu = false
100 | context.startService(
101 | Intent(
102 | context,
103 | BluetoothService::class.java
104 | ).apply {
105 | action = BluetoothService.ACTION_STOP
106 | }
107 | )
108 | }
109 | )
110 | }
111 | DropdownMenuItem(
112 | text = { Text(stringResource(R.string.settings)) },
113 | leadingIcon = { Icon(Icons.Outlined.Settings, null) },
114 | onClick = {
115 | showMenu = false
116 | context.startActivity(Intent(context, SettingsActivity::class.java))
117 | }
118 | )
119 | DropdownMenuItem(
120 | text = { Text(stringResource(R.string.exit)) },
121 | leadingIcon = { Icon(Icons.Default.Close, null) },
122 | onClick = {
123 | activity?.finishAfterTransition()
124 | }
125 | )
126 | }
127 | }
128 | }
129 |
130 | @Composable
131 | fun SettingsDropdown() {
132 | var showMenu by rememberSaveable {
133 | mutableStateOf(false)
134 | }
135 |
136 | val context = LocalContext.current
137 | var developerMode by rememberPreference(PreferenceStore.DEVELOPER_MODE)
138 | var ocrSupport by rememberPreference(PreferenceStore.OCR_COMPAT)
139 | var insecureRfcomm by rememberPreference(PreferenceStore.INSECURE_RFCOMM)
140 | val connectionMode by context.getPreferenceState(PreferenceStore.CONNECTION_MODE)
141 |
142 | val ocrInfoDialog = rememberDialogState()
143 | val insecureRfcommDialog = rememberDialogState()
144 |
145 | Box {
146 | IconButton(
147 | onClick = { showMenu = !showMenu },
148 | modifier = Modifier.tooltip(stringResource(R.string.more))
149 | ) {
150 | Icon(Icons.Default.MoreVert, "More options")
151 | }
152 |
153 | DropdownMenu(
154 | expanded = showMenu,
155 | modifier = Modifier.widthIn(min = 150.dp),
156 | onDismissRequest = { showMenu = false }) {
157 |
158 | DropdownMenuItem(
159 | text = { Text(stringResource(R.string.developer_mode)) },
160 | trailingIcon = {
161 | Checkbox(
162 | checked = developerMode,
163 | onCheckedChange = null
164 | )
165 | },
166 | onClick = {
167 | developerMode = !developerMode
168 | }
169 | )
170 |
171 | DropdownMenuItem(
172 | text = { Text(stringResource(R.string.ext_ocr_support)) },
173 | trailingIcon = {
174 | Checkbox(
175 | checked = ocrSupport,
176 | onCheckedChange = null
177 | )
178 | },
179 | onClick = {
180 | if (!ocrSupport) {
181 | ocrInfoDialog.open()
182 | }
183 | ocrSupport = !ocrSupport
184 | }
185 | )
186 |
187 | if (connectionMode == ConnectionMode.RFCOMM.ordinal) {
188 | DropdownMenuItem(
189 | text = { Text(stringResource(R.string.insecure_rfcomm)) },
190 | leadingIcon = { Icon(Icons.Default.Shield, null) },
191 | trailingIcon = {
192 | Checkbox(
193 | checked = insecureRfcomm,
194 | onCheckedChange = null
195 | )
196 | },
197 | onClick = {
198 | if (!insecureRfcomm) {
199 | insecureRfcommDialog.open()
200 | }
201 | insecureRfcomm = !insecureRfcomm
202 | }
203 | )
204 |
205 | var preserveUnsupportedPlaceholders by rememberPreference(PreferenceStore.PRESERVE_UNSUPPORTED_PLACEHOLDERS)
206 | DropdownMenuItem(
207 | text = { Text(stringResource(R.string.preserve_unsupported_placeholders)) },
208 | leadingIcon = { Icon(Icons.Default.DataObject, null) },
209 | trailingIcon = {
210 | Checkbox(
211 | checked = preserveUnsupportedPlaceholders,
212 | onCheckedChange = null
213 | )
214 | },
215 | onClick = {
216 | preserveUnsupportedPlaceholders = !preserveUnsupportedPlaceholders
217 | }
218 | )
219 | }
220 | }
221 | }
222 |
223 | InfoDialog(
224 | ocrInfoDialog, stringResource(R.string.note),
225 | icon = { Icon(Icons.Default.Info, null) }
226 | ) {
227 | val uriHandler = LocalUriHandler.current
228 |
229 | Column {
230 | Text(stringResource(R.string.ocr_note_desc))
231 |
232 | Column(Modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally) {
233 | OutlinedButton({
234 | uriHandler.openUri("https://github.com/mtotschnig/OCR")
235 | }) { Text(stringResource(R.string.ocr_app_github)) }
236 | }
237 |
238 | Text(stringResource(R.string.ocr_note_bottom))
239 | }
240 | }
241 |
242 | InfoDialog(
243 | insecureRfcommDialog, stringResource(R.string.note),
244 | icon = { Icon(Icons.Default.Shield, null) }
245 | ) {
246 | Column {
247 | Text(stringResource(R.string.insecure_rfcomm_desc))
248 | }
249 | }
250 | }
251 |
--------------------------------------------------------------------------------