├── app
├── .gitignore
├── src
│ ├── main
│ │ ├── res
│ │ │ ├── values
│ │ │ │ ├── strings.xml
│ │ │ │ ├── ic_icon_background.xml
│ │ │ │ ├── themes.xml
│ │ │ │ └── colors.xml
│ │ │ ├── POS-simple-optimized.png
│ │ │ ├── mipmap-hdpi
│ │ │ │ ├── ic_icon.png
│ │ │ │ ├── ic_icon_round.png
│ │ │ │ ├── ic_launcher.webp
│ │ │ │ └── ic_launcher_round.webp
│ │ │ ├── mipmap-mdpi
│ │ │ │ ├── ic_icon.png
│ │ │ │ ├── ic_icon_round.png
│ │ │ │ ├── ic_launcher.webp
│ │ │ │ └── ic_launcher_round.webp
│ │ │ ├── mipmap-xhdpi
│ │ │ │ ├── ic_icon.png
│ │ │ │ ├── ic_launcher.webp
│ │ │ │ ├── ic_icon_round.png
│ │ │ │ └── ic_launcher_round.webp
│ │ │ ├── mipmap-xxhdpi
│ │ │ │ ├── ic_icon.png
│ │ │ │ ├── ic_icon_round.png
│ │ │ │ ├── ic_launcher.webp
│ │ │ │ └── ic_launcher_round.webp
│ │ │ ├── mipmap-xxxhdpi
│ │ │ │ ├── ic_icon.png
│ │ │ │ ├── ic_icon_round.png
│ │ │ │ ├── ic_launcher.webp
│ │ │ │ └── ic_launcher_round.webp
│ │ │ ├── mipmap-anydpi-v26
│ │ │ │ ├── ic_icon.xml
│ │ │ │ ├── ic_icon_round.xml
│ │ │ │ ├── ic_launcher.xml
│ │ │ │ └── ic_launcher_round.xml
│ │ │ ├── mipmap-anydpi-v33
│ │ │ │ └── ic_launcher.xml
│ │ │ ├── drawable
│ │ │ │ ├── arrow_drop_down_24px.xml
│ │ │ │ ├── ic_launcher_foreground.xml
│ │ │ │ └── ic_launcher_background.xml
│ │ │ ├── xml
│ │ │ │ ├── backup_rules.xml
│ │ │ │ └── data_extraction_rules.xml
│ │ │ ├── values-night
│ │ │ │ └── themes.xml
│ │ │ └── drawable-v24
│ │ │ │ └── ic_launcher_foreground.xml
│ │ ├── java
│ │ │ └── com
│ │ │ │ └── farminos
│ │ │ │ └── print
│ │ │ │ ├── ui
│ │ │ │ └── theme
│ │ │ │ │ ├── Type.kt
│ │ │ │ │ ├── Shape.kt
│ │ │ │ │ └── Theme.kt
│ │ │ │ ├── OpenESCPOSPrintService.kt
│ │ │ │ ├── Utils.kt
│ │ │ │ ├── Service.kt
│ │ │ │ ├── Drivers.kt
│ │ │ │ ├── PrintActivity.kt
│ │ │ │ └── Ui.kt
│ │ ├── proto
│ │ │ └── settings.proto
│ │ └── AndroidManifest.xml
│ ├── test
│ │ └── java
│ │ │ └── com
│ │ │ └── farminos
│ │ │ └── print
│ │ │ └── ExampleUnitTest.kt
│ └── androidTest
│ │ └── java
│ │ └── com
│ │ └── farminos
│ │ └── print
│ │ └── ExampleInstrumentedTest.kt
├── libs
│ └── Citizen_Android_1093.jar
├── proguard-rules.pro
└── build.gradle.kts
├── .editorconfig
├── gradle
├── wrapper
│ ├── gradle-wrapper.jar
│ └── gradle-wrapper.properties
└── libs.versions.toml
├── .gitignore
├── settings.gradle.kts
├── gradle.properties
├── README.md
├── gradlew.bat
├── gradlew
└── LICENSE
/app/.gitignore:
--------------------------------------------------------------------------------
1 | /build
--------------------------------------------------------------------------------
/.editorconfig:
--------------------------------------------------------------------------------
1 | [*.{kt,kts}]
2 | ktlint_function_naming_ignore_when_annotated_with = Composable
3 |
--------------------------------------------------------------------------------
/app/src/main/res/values/strings.xml:
--------------------------------------------------------------------------------
1 |
2 | Open ESC/POS Print Service
3 |
--------------------------------------------------------------------------------
/app/libs/Citizen_Android_1093.jar:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/farminos/open-escpos-print-service/HEAD/app/libs/Citizen_Android_1093.jar
--------------------------------------------------------------------------------
/gradle/wrapper/gradle-wrapper.jar:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/farminos/open-escpos-print-service/HEAD/gradle/wrapper/gradle-wrapper.jar
--------------------------------------------------------------------------------
/app/src/main/res/POS-simple-optimized.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/farminos/open-escpos-print-service/HEAD/app/src/main/res/POS-simple-optimized.png
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-hdpi/ic_icon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/farminos/open-escpos-print-service/HEAD/app/src/main/res/mipmap-hdpi/ic_icon.png
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-mdpi/ic_icon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/farminos/open-escpos-print-service/HEAD/app/src/main/res/mipmap-mdpi/ic_icon.png
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-xhdpi/ic_icon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/farminos/open-escpos-print-service/HEAD/app/src/main/res/mipmap-xhdpi/ic_icon.png
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-xxhdpi/ic_icon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/farminos/open-escpos-print-service/HEAD/app/src/main/res/mipmap-xxhdpi/ic_icon.png
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-xxxhdpi/ic_icon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/farminos/open-escpos-print-service/HEAD/app/src/main/res/mipmap-xxxhdpi/ic_icon.png
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-hdpi/ic_icon_round.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/farminos/open-escpos-print-service/HEAD/app/src/main/res/mipmap-hdpi/ic_icon_round.png
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-hdpi/ic_launcher.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/farminos/open-escpos-print-service/HEAD/app/src/main/res/mipmap-hdpi/ic_launcher.webp
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-mdpi/ic_icon_round.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/farminos/open-escpos-print-service/HEAD/app/src/main/res/mipmap-mdpi/ic_icon_round.png
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-mdpi/ic_launcher.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/farminos/open-escpos-print-service/HEAD/app/src/main/res/mipmap-mdpi/ic_launcher.webp
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-xhdpi/ic_launcher.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/farminos/open-escpos-print-service/HEAD/app/src/main/res/mipmap-xhdpi/ic_launcher.webp
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-xhdpi/ic_icon_round.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/farminos/open-escpos-print-service/HEAD/app/src/main/res/mipmap-xhdpi/ic_icon_round.png
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-xxhdpi/ic_icon_round.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/farminos/open-escpos-print-service/HEAD/app/src/main/res/mipmap-xxhdpi/ic_icon_round.png
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/farminos/open-escpos-print-service/HEAD/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-xxxhdpi/ic_icon_round.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/farminos/open-escpos-print-service/HEAD/app/src/main/res/mipmap-xxxhdpi/ic_icon_round.png
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/farminos/open-escpos-print-service/HEAD/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/farminos/open-escpos-print-service/HEAD/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/farminos/open-escpos-print-service/HEAD/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/farminos/open-escpos-print-service/HEAD/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/farminos/open-escpos-print-service/HEAD/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/farminos/open-escpos-print-service/HEAD/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp
--------------------------------------------------------------------------------
/app/src/main/res/values/ic_icon_background.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 | #FFFFFF
4 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | *.iml
2 | app/debug
3 | app/release
4 | .gradle
5 | /local.properties
6 | .idea/workspace.xml
7 | .DS_Store
8 | /build
9 | /captures
10 | .externalNativeBuild
11 | .cxx
12 | local.properties
13 |
--------------------------------------------------------------------------------
/app/src/main/java/com/farminos/print/ui/theme/Type.kt:
--------------------------------------------------------------------------------
1 | package com.farminos.print.ui.theme
2 |
3 | import androidx.compose.material3.Typography
4 |
5 | // Set of Material typography styles to start with
6 | val Typography = Typography()
7 |
--------------------------------------------------------------------------------
/gradle/wrapper/gradle-wrapper.properties:
--------------------------------------------------------------------------------
1 | #Fri Oct 18 23:36:10 RET 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 |
--------------------------------------------------------------------------------
/app/src/main/res/values/themes.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
7 |
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-anydpi-v26/ic_icon.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-anydpi-v26/ic_icon_round.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-anydpi-v33/ic_launcher.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/app/src/main/java/com/farminos/print/ui/theme/Shape.kt:
--------------------------------------------------------------------------------
1 | package com.farminos.print.ui.theme
2 |
3 | import androidx.compose.foundation.shape.RoundedCornerShape
4 | import androidx.compose.material3.Shapes
5 | import androidx.compose.ui.unit.dp
6 |
7 | val Shapes =
8 | Shapes(
9 | small = RoundedCornerShape(4.dp),
10 | medium = RoundedCornerShape(4.dp),
11 | large = RoundedCornerShape(0.dp),
12 | )
13 |
--------------------------------------------------------------------------------
/app/src/main/res/drawable/arrow_drop_down_24px.xml:
--------------------------------------------------------------------------------
1 |
7 |
10 |
11 |
--------------------------------------------------------------------------------
/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/com/farminos/print/ExampleUnitTest.kt:
--------------------------------------------------------------------------------
1 | package com.farminos.print
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 | }
17 |
--------------------------------------------------------------------------------
/app/src/main/res/xml/backup_rules.xml:
--------------------------------------------------------------------------------
1 |
8 |
9 |
13 |
--------------------------------------------------------------------------------
/app/src/main/res/xml/data_extraction_rules.xml:
--------------------------------------------------------------------------------
1 |
6 |
7 |
8 |
12 |
13 |
19 |
--------------------------------------------------------------------------------
/settings.gradle.kts:
--------------------------------------------------------------------------------
1 | pluginManagement {
2 | repositories {
3 | google {
4 | content {
5 | includeGroupByRegex("com\\.android.*")
6 | includeGroupByRegex("com\\.google.*")
7 | includeGroupByRegex("androidx.*")
8 | }
9 | }
10 | mavenCentral()
11 | gradlePluginPortal()
12 | }
13 | }
14 | dependencyResolutionManagement {
15 | repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
16 | repositories {
17 | google()
18 | mavenCentral()
19 | maven("https://jitpack.io")
20 | }
21 | }
22 |
23 | rootProject.name = "Open ESCPOS Print Service"
24 | include(":app")
25 |
--------------------------------------------------------------------------------
/app/src/main/java/com/farminos/print/OpenESCPOSPrintService.kt:
--------------------------------------------------------------------------------
1 | package com.farminos.print
2 |
3 | import android.app.Application
4 | import com.citizen.port.android.BluetoothPort
5 | import com.citizen.port.android.WiFiPort
6 | import com.dantsu.escposprinter.connection.bluetooth.BluetoothConnection
7 | import com.dantsu.escposprinter.connection.tcp.TcpConnection
8 |
9 | class OpenESCPOSPrintService : Application() {
10 | val escPosBluetoothSockets: MutableMap = mutableMapOf()
11 | val escPosTcpSockets: MutableMap = mutableMapOf()
12 | val cpclBluetoothSockets: MutableMap = mutableMapOf()
13 | val cpclTcpSockets: MutableMap = mutableMapOf()
14 | }
15 |
--------------------------------------------------------------------------------
/app/src/androidTest/java/com/farminos/print/ExampleInstrumentedTest.kt:
--------------------------------------------------------------------------------
1 | package com.farminos.print
2 |
3 | import androidx.test.ext.junit.runners.AndroidJUnit4
4 | import androidx.test.platform.app.InstrumentationRegistry
5 | import org.junit.Assert.assertEquals
6 | import org.junit.Test
7 | import org.junit.runner.RunWith
8 |
9 | /**
10 | * Instrumented test, which will execute on an Android device.
11 | *
12 | * See [testing documentation](http://d.android.com/tools/testing).
13 | */
14 | @RunWith(AndroidJUnit4::class)
15 | class ExampleInstrumentedTest {
16 | @Test
17 | fun useAppContext() {
18 | // Context of the app under test.
19 | val appContext = InstrumentationRegistry.getInstrumentation().targetContext
20 | assertEquals("com.farminos.print", appContext.packageName)
21 | }
22 | }
23 |
--------------------------------------------------------------------------------
/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/res/values-night/themes.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
16 |
--------------------------------------------------------------------------------
/app/src/main/proto/settings.proto:
--------------------------------------------------------------------------------
1 | syntax = "proto3";
2 |
3 | option java_package = "com.farminos.print";
4 | option java_multiple_files = true;
5 |
6 | enum Driver {
7 | ESC_POS = 0;
8 | CPCL = 1;
9 | }
10 |
11 | enum Interface {
12 | BLUETOOTH = 0;
13 | TCP_IP = 1;
14 | }
15 |
16 | message PrinterSettings {
17 | bool enabled = 1;
18 | Driver driver = 2;
19 | int32 dpi = 3;
20 | float width = 4;
21 | float height = 5;
22 | float margin_left = 6;
23 | bool cut = 7;
24 | float speed_limit = 8;
25 | float cut_delay = 9;
26 | float margin_right = 10;
27 | float margin_top = 11;
28 | float margin_bottom = 12;
29 | string address = 13; // only for tcp/ip printers
30 | string name = 14; // only for tcp/ip printers
31 | Interface interface = 15;
32 | bool keep_alive = 16;
33 | bool skip_white_lines_at_page_end = 17;
34 | }
35 |
36 | message Settings {
37 | string default_printer = 1;
38 | // bluetooth printers: mac address -> settings; ip printers: uuid -> settings
39 | map printers = 2;
40 | }
41 |
--------------------------------------------------------------------------------
/app/src/main/java/com/farminos/print/ui/theme/Theme.kt:
--------------------------------------------------------------------------------
1 | package com.farminos.print.ui.theme
2 |
3 | import androidx.compose.foundation.isSystemInDarkTheme
4 | import androidx.compose.material3.MaterialTheme
5 | import androidx.compose.material3.darkColorScheme
6 | import androidx.compose.material3.lightColorScheme
7 | import androidx.compose.runtime.Composable
8 |
9 | private val AppDarkColorScheme = darkColorScheme()
10 |
11 | private val AppLightColorScheme =
12 | lightColorScheme(
13 | /* Other default colors to override
14 | */
15 | )
16 |
17 | @Composable
18 | fun OpenESCPOSPrintServiceTheme(
19 | darkTheme: Boolean = isSystemInDarkTheme(),
20 | content: @Composable () -> Unit,
21 | ) {
22 | val colorScheme =
23 | if (darkTheme) {
24 | AppDarkColorScheme
25 | } else {
26 | AppLightColorScheme
27 | }
28 |
29 | MaterialTheme(
30 | colorScheme = colorScheme,
31 | typography = Typography,
32 | shapes = Shapes,
33 | content = content,
34 | )
35 | }
36 |
--------------------------------------------------------------------------------
/gradle.properties:
--------------------------------------------------------------------------------
1 | # Project-wide Gradle settings.
2 | # IDE (e.g. Android Studio) users:
3 | # Gradle settings configured through the IDE *will override*
4 | # any settings specified in this file.
5 | # For more details on how to configure your build environment visit
6 | # http://www.gradle.org/docs/current/userguide/build_environment.html
7 | # Specifies the JVM arguments used for the daemon process.
8 | # The setting is particularly useful for tweaking memory settings.
9 | org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
10 | # When configured, Gradle will run in incubating parallel mode.
11 | # This option should only be used with decoupled projects. For more details, visit
12 | # https://developer.android.com/r/tools/gradle-multi-project-decoupled-projects
13 | # org.gradle.parallel=true
14 | # AndroidX package structure to make it clearer which packages are bundled with the
15 | # Android operating system, and which are packaged with your app's APK
16 | # https://developer.android.com/topic/libraries/support-library/androidx-rn
17 | android.useAndroidX=true
18 | # Kotlin code style for this project: "official" or "obsolete":
19 | kotlin.code.style=official
20 | # Enables namespacing of each library's R class so that its R class includes only the
21 | # resources declared in the library itself and none from the library's dependencies,
22 | # thereby reducing the size of the R class for that library
23 | android.nonTransitiveRClass=true
--------------------------------------------------------------------------------
/app/src/main/res/drawable/ic_launcher_foreground.xml:
--------------------------------------------------------------------------------
1 |
7 |
8 |
9 |
15 |
18 |
21 |
22 |
23 |
24 |
30 |
--------------------------------------------------------------------------------
/app/src/main/res/drawable-v24/ic_launcher_foreground.xml:
--------------------------------------------------------------------------------
1 |
7 |
8 |
9 |
15 |
18 |
21 |
22 |
23 |
24 |
30 |
--------------------------------------------------------------------------------
/app/build.gradle.kts:
--------------------------------------------------------------------------------
1 | plugins {
2 | alias(libs.plugins.android.application)
3 | alias(libs.plugins.kotlin.android)
4 | alias(libs.plugins.compose.compiler)
5 | alias(libs.plugins.protobuf.gradle.plugin)
6 | alias(libs.plugins.kotlinter.plugin)
7 | }
8 |
9 | protobuf {
10 | protoc {
11 | artifact = "com.google.protobuf:protoc:3.23.4"
12 | }
13 | generateProtoTasks {
14 | all().forEach {
15 | it.builtins {
16 | create("java") {
17 | option("lite")
18 | }
19 | }
20 | }
21 | }
22 | }
23 |
24 | android {
25 | namespace = "com.farminos.print"
26 | compileSdk = 36
27 |
28 | buildFeatures {
29 | compose = true
30 | }
31 | composeOptions {
32 | kotlinCompilerExtensionVersion = "1.5.14"
33 | }
34 |
35 | defaultConfig {
36 | applicationId = "com.farminos.print"
37 | minSdk = 23
38 | targetSdk = 36
39 | versionCode = 26
40 | versionName = "1.2.4"
41 |
42 | testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
43 | }
44 |
45 | buildTypes {
46 | release {
47 | isMinifyEnabled = false
48 | proguardFiles(
49 | getDefaultProguardFile("proguard-android-optimize.txt"),
50 | "proguard-rules.pro"
51 | )
52 | }
53 | }
54 | compileOptions {
55 | sourceCompatibility = JavaVersion.VERSION_17
56 | targetCompatibility = JavaVersion.VERSION_17
57 | }
58 | kotlinOptions {
59 | jvmTarget = "17"
60 | }
61 |
62 | buildToolsVersion = "35.0.0"
63 | }
64 |
65 | dependencies {
66 | implementation(files("libs/Citizen_Android_1093.jar"))
67 | implementation(libs.androidx.activity.compose)
68 | implementation(libs.androidx.animation.android)
69 | implementation(libs.androidx.appcompat)
70 | implementation(libs.androidx.core.ktx)
71 | implementation(libs.androidx.datastore)
72 | implementation(libs.androidx.exifinterface)
73 | implementation(libs.androidx.foundation.android)
74 | implementation(libs.androidx.material3.android)
75 | implementation(libs.escpos.thermalprinter.android)
76 | implementation(libs.html2bitmap)
77 | implementation(libs.material)
78 | implementation(libs.protobuf.gradle.plugin)
79 | implementation(libs.protobuf.kotlin.lite)
80 | testImplementation(libs.junit)
81 | androidTestImplementation(libs.androidx.espresso.core)
82 | androidTestImplementation(libs.androidx.junit)
83 | }
84 |
--------------------------------------------------------------------------------
/gradle/libs.versions.toml:
--------------------------------------------------------------------------------
1 | [versions]
2 | activityCompose = "1.12.2"
3 | agp = "8.12.3"
4 | animationAndroid = "1.10.0"
5 | appcompat = "1.7.1"
6 | coreKtx = "1.17.0"
7 | datastore = "1.2.0"
8 | escposThermalprinterAndroid = "3.3.0"
9 | espressoCore = "3.7.0"
10 | exifinterface = "1.4.2"
11 | foundationAndroid = "1.10.0"
12 | html2bitmap = "1.10"
13 | junit = "4.13.2"
14 | junitVersion = "1.3.0"
15 | kotlin = "2.2.0"
16 | kotlinterGradlePlugin = "5.2.0"
17 | material = "1.13.0"
18 | material3Android = "1.4.0"
19 | protobufGradlePlugin = "0.9.6"
20 | protobufKotlinLite = "4.33.2"
21 |
22 | [libraries]
23 | androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
24 | androidx-animation-android = { group = "androidx.compose.animation", name = "animation-android", version.ref = "animationAndroid" }
25 | androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" }
26 | androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
27 | androidx-datastore = { group = "androidx.datastore", name = "datastore", version.ref = "datastore" }
28 | androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" }
29 | androidx-exifinterface = { group = "androidx.exifinterface", name = "exifinterface", version.ref = "exifinterface" }
30 | androidx-foundation-android = { group = "androidx.compose.foundation", name = "foundation-android", version.ref = "foundationAndroid" }
31 | androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" }
32 | androidx-material3-android = { group = "androidx.compose.material3", name = "material3-android", version.ref = "material3Android" }
33 | escpos-thermalprinter-android = { group = "com.github.DantSu", name = "ESCPOS-ThermalPrinter-Android", version.ref = "escposThermalprinterAndroid" }
34 | html2bitmap = { group = "com.izettle", name = "html2bitmap", version.ref = "html2bitmap" }
35 | junit = { group = "junit", name = "junit", version.ref = "junit" }
36 | material = { group = "com.google.android.material", name = "material", version.ref = "material" }
37 | protobuf-gradle-plugin = { group = "com.google.protobuf", name = "protobuf-gradle-plugin", version.ref = "protobufGradlePlugin" }
38 | protobuf-kotlin-lite = { group = "com.google.protobuf", name = "protobuf-kotlin-lite", version.ref = "protobufKotlinLite" }
39 |
40 | [plugins]
41 | android-application = { id = "com.android.application", version.ref = "agp" }
42 | compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
43 | kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
44 | kotlinter-plugin = { id = "org.jmailen.kotlinter", version.ref = "kotlinterGradlePlugin" }
45 | protobuf-gradle-plugin = { id = "com.google.protobuf", version.ref = "protobufGradlePlugin" }
--------------------------------------------------------------------------------
/app/src/main/AndroidManifest.xml:
--------------------------------------------------------------------------------
1 |
2 |
4 |
5 |
7 |
9 |
13 |
14 |
15 |
16 |
26 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 |
45 |
46 |
47 |
48 |
49 |
50 |
51 |
52 |
54 |
55 |
56 |
57 |
58 |
59 |
60 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # open-escpos-print-service
2 |
3 | This is an Android app that provides a [PrintService](https://developer.android.com/reference/android/printservice/PrintService) for label and receipt printers.
4 |
5 | It supports ESC/POS printers like the Netum G5 or MTP-II.
6 | It also supports Citizen printers using the CPCL protocol.
7 |
8 | You can connect printers through Bluetooth or a TCP socket.
9 |
10 | ## How
11 | * Install the app;
12 | * enable Bluetooth;
13 | * pair your Bluetooth printers with your phone;
14 | * open the app;
15 | * the Bluetooth printers should be there, configure the paper size;
16 | * for TCP printers, just add an address and a port separated by a ":" and configure the paper size;
17 | * you should now be able to print from any Android app (Chromium, image gallery, etc...).
18 |
19 | ## Using through an intent
20 | If you need to print from an app that does not support printing, or without going through the Android printer selection screen,
21 | you can send an intent to this app with the following format:
22 |
23 | * `scheme`: `print-intent`
24 | * `S.content`: a base64 encoded gzipped JSON array of strings, each string is an HTML document.
25 |
26 | It looks like this:
27 | `intent://#Intent;scheme=print-intent;S.content=H4sIAAAAAAAA...XXXXX;end`
28 |
29 | Sample code for generating the intent url:
30 | ```typescript
31 | import * as base64 from "js-base64";
32 | import * as pako from "pako";
33 |
34 | const pages = [
35 | "page 1",
36 | "page 2",
37 | ];
38 |
39 | function buildIntentUrl(data: Record) {
40 | const content = Object.entries({
41 | scheme: "print-intent",
42 | ...data,
43 | }).map(([key, value]) => [key, encodeURIComponent(value)].join("="));
44 | return `intent://${["#Intent", ...content, "end"].join(";")}`;
45 | }
46 |
47 | const intentUrl = buildIntentUrl({
48 | "S.content": base64.fromUint8Array(pako.gzip(JSON.stringify(pages))),
49 | });
50 | ```
51 |
52 | You might want to add some inline css in your html to reset the margins:
53 |
54 | ```css
55 | @page {
56 | margin: 0mm 0mm 0mm 0mm;
57 | }
58 |
59 | @media all {
60 | body {
61 | width: 100%;
62 | margin: 0;
63 | }
64 | }
65 | ```
66 |
67 | ## Using via `ACTION_SEND` or `ACTION_SEND_MULTIPLE`
68 |
69 | You can share images to this app, they will be printed on the default printer.
70 |
71 | ## Details
72 | If you have an ESC/POS __label__ printer, enable the `Cut after each page` switch, this will make the printer go to the start of the next label (at least on the Netum ones).
73 | There are speed limits and delays that you can set in each printer settings; if your printer works well, leave these at 0.
74 |
75 | ## TODO
76 | Some things are not implemented yet:
77 | * a document print queue;
78 | * discovery of network printers through mDNS.
79 |
80 | ## Linter
81 | Please lint with `./gradlew lintKotlin` and format code with `./gradlew formatKotlin` before commiting.
82 |
--------------------------------------------------------------------------------
/gradlew.bat:
--------------------------------------------------------------------------------
1 | @rem
2 | @rem Copyright 2015 the original author or authors.
3 | @rem
4 | @rem Licensed under the Apache License, Version 2.0 (the "License");
5 | @rem you may not use this file except in compliance with the License.
6 | @rem You may obtain a copy of the License at
7 | @rem
8 | @rem https://www.apache.org/licenses/LICENSE-2.0
9 | @rem
10 | @rem Unless required by applicable law or agreed to in writing, software
11 | @rem distributed under the License is distributed on an "AS IS" BASIS,
12 | @rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | @rem See the License for the specific language governing permissions and
14 | @rem limitations under the License.
15 | @rem
16 |
17 | @if "%DEBUG%" == "" @echo off
18 | @rem ##########################################################################
19 | @rem
20 | @rem Gradle startup script for Windows
21 | @rem
22 | @rem ##########################################################################
23 |
24 | @rem Set local scope for the variables with windows NT shell
25 | if "%OS%"=="Windows_NT" setlocal
26 |
27 | set DIRNAME=%~dp0
28 | if "%DIRNAME%" == "" set DIRNAME=.
29 | set APP_BASE_NAME=%~n0
30 | set APP_HOME=%DIRNAME%
31 |
32 | @rem Resolve any "." and ".." in APP_HOME to make it shorter.
33 | for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
34 |
35 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
36 | set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
37 |
38 | @rem Find java.exe
39 | if defined JAVA_HOME goto findJavaFromJavaHome
40 |
41 | set JAVA_EXE=java.exe
42 | %JAVA_EXE% -version >NUL 2>&1
43 | if "%ERRORLEVEL%" == "0" goto execute
44 |
45 | echo.
46 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
47 | echo.
48 | echo Please set the JAVA_HOME variable in your environment to match the
49 | echo location of your Java installation.
50 |
51 | goto fail
52 |
53 | :findJavaFromJavaHome
54 | set JAVA_HOME=%JAVA_HOME:"=%
55 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe
56 |
57 | if exist "%JAVA_EXE%" goto execute
58 |
59 | echo.
60 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
61 | echo.
62 | echo Please set the JAVA_HOME variable in your environment to match the
63 | echo location of your Java installation.
64 |
65 | goto fail
66 |
67 | :execute
68 | @rem Setup the command line
69 |
70 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
71 |
72 |
73 | @rem Execute Gradle
74 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
75 |
76 | :end
77 | @rem End local scope for the variables with windows NT shell
78 | if "%ERRORLEVEL%"=="0" goto mainEnd
79 |
80 | :fail
81 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
82 | rem the _cmd.exe /c_ return code!
83 | if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
84 | exit /b 1
85 |
86 | :mainEnd
87 | if "%OS%"=="Windows_NT" endlocal
88 |
89 | :omega
90 |
--------------------------------------------------------------------------------
/app/src/main/res/drawable/ic_launcher_background.xml:
--------------------------------------------------------------------------------
1 |
2 |
7 |
10 |
15 |
20 |
25 |
30 |
35 |
40 |
45 |
50 |
55 |
60 |
65 |
70 |
75 |
80 |
85 |
90 |
95 |
100 |
105 |
110 |
115 |
120 |
125 |
130 |
135 |
140 |
145 |
150 |
155 |
160 |
165 |
170 |
171 |
--------------------------------------------------------------------------------
/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/com/farminos/print/Utils.kt:
--------------------------------------------------------------------------------
1 | package com.farminos.print
2 |
3 | import android.graphics.Bitmap
4 | import android.graphics.Canvas
5 | import android.graphics.Color
6 | import android.graphics.Matrix
7 | import android.graphics.Paint
8 | import android.graphics.RectF
9 | import android.graphics.pdf.PdfRenderer
10 | import android.os.ParcelFileDescriptor
11 | import androidx.exifinterface.media.ExifInterface
12 | import java.io.ByteArrayInputStream
13 | import java.io.File
14 | import java.io.FileDescriptor
15 | import java.io.FileInputStream
16 | import java.io.FileOutputStream
17 | import java.io.IOException
18 | import java.util.zip.GZIPInputStream
19 | import kotlin.math.ceil
20 | import kotlin.math.min
21 |
22 | @Throws(IOException::class)
23 | fun decompress(compressed: ByteArray?): String {
24 | val bufferSize = 32
25 | val builder = StringBuilder()
26 | val data = ByteArray(bufferSize)
27 | var bytesRead: Int
28 | ByteArrayInputStream(compressed).use { inputStream ->
29 | GZIPInputStream(inputStream, bufferSize).use { gis ->
30 | while (gis.read(data).also { bytesRead = it } != -1) {
31 | builder.append(String(data, 0, bytesRead))
32 | }
33 | }
34 | }
35 | return builder.toString()
36 | }
37 |
38 | fun convertTransparentToWhite(bitmap: Bitmap) {
39 | val pixels = IntArray(bitmap.height * bitmap.width)
40 | bitmap.getPixels(
41 | pixels,
42 | 0,
43 | bitmap.width,
44 | 0,
45 | 0,
46 | bitmap.width,
47 | bitmap.height,
48 | )
49 | for (j in pixels.indices) {
50 | if (pixels[j] == Color.TRANSPARENT) {
51 | pixels[j] = Color.WHITE
52 | }
53 | }
54 | bitmap.setPixels(
55 | pixels,
56 | 0,
57 | bitmap.width,
58 | 0,
59 | 0,
60 | bitmap.width,
61 | bitmap.height,
62 | )
63 | }
64 |
65 | fun pdfToBitmaps(
66 | document: ParcelFileDescriptor,
67 | dpi: Int,
68 | w: Float,
69 | h: Float,
70 | ) = sequence {
71 | val renderer = PdfRenderer(document)
72 | val pageCount = renderer.pageCount
73 | for (i in 0 until pageCount) {
74 | val width = cmToDots(w, dpi)
75 | val height = cmToDots(h, dpi)
76 | val page = renderer.openPage(i)
77 | val transform = Matrix()
78 | val ratio = width.toFloat() / page.width
79 | transform.postScale(ratio, ratio)
80 | val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888)
81 | convertTransparentToWhite(bitmap)
82 | page.render(bitmap, null, transform, PdfRenderer.Page.RENDER_MODE_FOR_PRINT)
83 | yield(bitmap)
84 | page.close()
85 | }
86 | renderer.close()
87 | }
88 |
89 | fun bitmapSlices(
90 | bitmap: Bitmap,
91 | step: Int,
92 | ) = sequence {
93 | val width: Int = bitmap.width
94 | val height: Int = bitmap.height
95 | for (y in 0 until height step step) {
96 | val slice =
97 | Bitmap.createBitmap(
98 | bitmap,
99 | 0,
100 | y,
101 | width,
102 | if (y + step >= height) height - y else step,
103 | )
104 | yield(slice)
105 | }
106 | }
107 |
108 | data class Tile(
109 | val x: Int,
110 | val y: Int,
111 | val width: Int,
112 | val height: Int,
113 | )
114 |
115 | fun bitmapTiles(
116 | bitmap: Bitmap,
117 | tileWidth: Int,
118 | tileHeight: Int,
119 | ) = sequence {
120 | for (y in 0 until bitmap.height step tileHeight) {
121 | for (x in 0 until bitmap.width step tileWidth) {
122 | val width = min(tileWidth, bitmap.width - x)
123 | val height = min(tileHeight, bitmap.height - y)
124 | yield(Tile(x, y, width, height))
125 | }
126 | }
127 | }
128 |
129 | fun bitmapRegionIsWhite(
130 | bitmap: Bitmap,
131 | tile: Tile,
132 | ): Boolean {
133 | val pixels = IntArray(tile.width * tile.height)
134 | bitmap.getPixels(pixels, 0, tile.width, tile.x, tile.y, tile.width, tile.height)
135 | for (pixel in pixels) {
136 | if (pixel != Color.WHITE) {
137 | return false
138 | }
139 | }
140 | return true
141 | }
142 |
143 | fun bitmapNonEmptyTiles(
144 | bitmap: Bitmap,
145 | tileSize: Int,
146 | ) = sequence {
147 | for (tile in bitmapTiles(bitmap, tileSize, tileSize)) {
148 | if (!bitmapRegionIsWhite(bitmap, tile)) {
149 | yield(tile)
150 | }
151 | }
152 | }
153 |
154 | fun bitmapLastNonWhiteLine(bitmap: Bitmap): Int {
155 | var lastNonWhiteLine = 0
156 | for ((index, line) in bitmapTiles(bitmap, bitmap.width, 1).withIndex()) {
157 | if (!bitmapRegionIsWhite(bitmap, line)) {
158 | lastNonWhiteLine = index
159 | }
160 | }
161 | return lastNonWhiteLine
162 | }
163 |
164 | fun bitmapCropWhiteEnd(bitmap: Bitmap): Bitmap {
165 | val lastNonWhiteLine = bitmapLastNonWhiteLine(bitmap)
166 | val height = lastNonWhiteLine + 1
167 | if (bitmap.height == height) {
168 | return bitmap
169 | }
170 | return Bitmap.createBitmap(bitmap, 0, 0, bitmap.width, height)
171 | }
172 |
173 | fun copyToTmpFile(
174 | cacheDir: File,
175 | fd: FileDescriptor,
176 | ): ParcelFileDescriptor {
177 | val outputFile = File.createTempFile(System.currentTimeMillis().toString(), null, cacheDir)
178 | val buffer = ByteArray(8192)
179 | var length: Int
180 | FileOutputStream(outputFile).use { outputStream ->
181 | FileInputStream(fd).use { inputStream ->
182 | while (inputStream.read(buffer).also { length = it } > 0) {
183 | outputStream.write(buffer, 0, length)
184 | }
185 | }
186 | }
187 | return ParcelFileDescriptor.open(outputFile, ParcelFileDescriptor.MODE_READ_ONLY)
188 | }
189 |
190 | fun addMargins(
191 | bitmap: Bitmap,
192 | marginLeftPx: Int,
193 | marginTopPx: Int,
194 | marginRightPx: Int,
195 | marginBottomPx: Int,
196 | ): Bitmap {
197 | val result =
198 | Bitmap.createBitmap(
199 | marginLeftPx + bitmap.width + marginRightPx,
200 | marginTopPx + bitmap.height + marginBottomPx,
201 | bitmap.config ?: Bitmap.Config.ARGB_8888,
202 | )
203 | result.eraseColor(Color.WHITE)
204 | val canvas = Canvas(result)
205 | canvas.drawBitmap(bitmap, marginLeftPx.toFloat(), marginTopPx.toFloat(), Paint())
206 | return result
207 | }
208 |
209 | private const val INCH = 2.54F
210 |
211 | private fun cmToDots(
212 | cm: Float,
213 | dpi: Int,
214 | ): Int = ceil((cm / INCH) * dpi).toInt()
215 |
216 | fun cmToMils(cm: Float): Int = ceil(cm / INCH * 1000).toInt()
217 |
218 | fun cmToPixels(
219 | cm: Float,
220 | dpi: Int,
221 | ): Int = (cm / INCH * dpi).toInt()
222 |
223 | fun pixelsToCm(
224 | pixels: Int,
225 | dpi: Int,
226 | ): Float = pixels.toFloat() / dpi * INCH
227 |
228 | fun scaleBitmap(
229 | bitmap: Bitmap,
230 | printerSettings: PrinterSettings,
231 | ): Bitmap {
232 | val width = printerSettings.width
233 | val marginLeft = printerSettings.marginLeft
234 | val marginTop = printerSettings.marginTop
235 | val marginRight = printerSettings.marginRight
236 | val marginBottom = printerSettings.marginBottom
237 | val dpi = printerSettings.dpi
238 | val widthPx = cmToPixels(width, dpi)
239 | val marginLeftPx = cmToPixels(marginLeft, dpi)
240 | val marginTopPx = cmToPixels(marginTop, dpi)
241 | val marginRightPx = cmToPixels(marginRight, dpi)
242 | val marginBottomPx = cmToPixels(marginBottom, dpi)
243 | val renderWidthPx = widthPx - marginLeftPx - marginRightPx
244 | val ratio = renderWidthPx.toFloat() / bitmap.width
245 | val resizedBitmap = Bitmap.createScaledBitmap(bitmap, renderWidthPx, (bitmap.height * ratio).toInt(), true)
246 | return if (marginLeftPx == 0 && marginTopPx == 0 && marginRightPx == 0 && marginBottomPx == 0) {
247 | resizedBitmap
248 | } else {
249 | addMargins(resizedBitmap, marginLeftPx, marginTopPx, marginRightPx, marginBottomPx)
250 | }
251 | }
252 |
253 | fun rotateBitmap(
254 | bitmap: Bitmap,
255 | orientation: Int,
256 | ): Bitmap {
257 | val matrix = Matrix()
258 | when (orientation) {
259 | ExifInterface.ORIENTATION_ROTATE_90 -> {
260 | matrix.postRotate(90f)
261 | matrix.postTranslate(bitmap.height.toFloat(), 0f)
262 | }
263 | ExifInterface.ORIENTATION_TRANSVERSE -> {
264 | matrix.postScale(-1f, 1f)
265 | matrix.postTranslate(bitmap.width.toFloat(), 0f)
266 | matrix.postRotate(90f)
267 | matrix.postTranslate(bitmap.height.toFloat(), 0f)
268 | }
269 | ExifInterface.ORIENTATION_ROTATE_180 -> {
270 | matrix.postRotate(180f)
271 | matrix.postTranslate(bitmap.width.toFloat(), bitmap.height.toFloat())
272 | }
273 | ExifInterface.ORIENTATION_ROTATE_270 -> {
274 | matrix.postRotate(270f)
275 | matrix.postTranslate(0f, bitmap.width.toFloat())
276 | }
277 | ExifInterface.ORIENTATION_TRANSPOSE -> {
278 | matrix.postScale(-1f, 1f)
279 | matrix.postTranslate(bitmap.width.toFloat(), 0f)
280 | matrix.postRotate(270f)
281 | matrix.postTranslate(0f, bitmap.width.toFloat())
282 | }
283 | ExifInterface.ORIENTATION_FLIP_HORIZONTAL -> {
284 | matrix.postScale(-1f, 1f)
285 | matrix.postTranslate(bitmap.width.toFloat(), 0f)
286 | }
287 | ExifInterface.ORIENTATION_FLIP_VERTICAL -> {
288 | matrix.postScale(1f, -1f)
289 | matrix.postTranslate(0f, bitmap.height.toFloat())
290 | }
291 | }
292 | val rotatedRect = RectF(0f, 0f, bitmap.width.toFloat(), bitmap.height.toFloat())
293 | matrix.mapRect(rotatedRect)
294 | val newWidth = rotatedRect.width().toInt()
295 | val newHeight = rotatedRect.height().toInt()
296 | return Bitmap.createBitmap(newWidth, newHeight, bitmap.config ?: Bitmap.Config.ARGB_8888).apply {
297 | val canvas = Canvas(this)
298 | canvas.concat(matrix)
299 | canvas.drawBitmap(bitmap, 0f, 0f, null)
300 | }
301 | }
302 |
--------------------------------------------------------------------------------
/app/src/main/java/com/farminos/print/Service.kt:
--------------------------------------------------------------------------------
1 | package com.farminos.print
2 |
3 | import android.Manifest
4 | import android.bluetooth.BluetoothAdapter
5 | import android.bluetooth.BluetoothManager
6 | import android.content.pm.PackageManager
7 | import android.os.ParcelFileDescriptor
8 | import android.print.PrintAttributes
9 | import android.print.PrintAttributes.Margins
10 | import android.print.PrintAttributes.Resolution
11 | import android.print.PrintJobInfo
12 | import android.print.PrinterCapabilitiesInfo
13 | import android.print.PrinterId
14 | import android.print.PrinterInfo
15 | import android.printservice.PrintJob
16 | import android.printservice.PrintService
17 | import android.printservice.PrinterDiscoverySession
18 | import androidx.core.app.ActivityCompat
19 | import androidx.core.content.ContextCompat
20 | import kotlinx.coroutines.CoroutineScope
21 | import kotlinx.coroutines.Dispatchers
22 | import kotlinx.coroutines.Job
23 | import kotlinx.coroutines.cancel
24 | import kotlinx.coroutines.flow.collect
25 | import kotlinx.coroutines.flow.map
26 | import kotlinx.coroutines.launch
27 | import kotlinx.coroutines.withContext
28 | import java.text.DecimalFormat
29 |
30 | data class Printer(
31 | val address: String,
32 | val name: String,
33 | )
34 |
35 | class FarminOSPrinterDiscoverySession(
36 | private val context: FarminOSPrintService,
37 | ) : PrinterDiscoverySession() {
38 | private val scope = CoroutineScope(Dispatchers.Main)
39 | private var job: Job? = null
40 |
41 | private suspend fun settingsObserver() {
42 | context.settingsDataStore.data
43 | .map { settings ->
44 | val oldIds = printers.map { it.id }
45 | val newPrinters = listPrinters(settings)
46 | val newPrinterIds = newPrinters.map { it.info.id }
47 | // remove no longer present printers
48 | oldIds.forEach {
49 | if (!newPrinterIds.contains(it)) {
50 | context.printersMap.remove(it)
51 | removePrinters(listOf(it))
52 | }
53 | }
54 | // add or update printers
55 | newPrinters.forEach {
56 | context.printersMap[it.info.id] = it
57 | addPrinters(listOf(it.info))
58 | }
59 | }.collect()
60 | }
61 |
62 | override fun onStartPrinterDiscovery(priorityList: MutableList) {
63 | job =
64 | scope.launch {
65 | settingsObserver()
66 | }
67 | }
68 |
69 | private fun listBluetoothPrinters(settings: Settings): List {
70 | if (ActivityCompat.checkSelfPermission(
71 | context,
72 | Manifest.permission.BLUETOOTH_CONNECT,
73 | ) != PackageManager.PERMISSION_GRANTED
74 | ) {
75 | return listOf()
76 | }
77 | val bluetoothAdapter = ContextCompat.getSystemService(context, BluetoothManager::class.java)?.adapter ?: return listOf()
78 | val printers =
79 | settings.printersMap
80 | .filterValues { it.enabled && it.`interface` == Interface.BLUETOOTH }
81 | .mapNotNull { (uuid, printerSettings) ->
82 | val id = context.generatePrinterId(uuid)
83 | val btPrinter = bluetoothAdapter.bondedDevices.find { it.address == uuid } ?: return@mapNotNull null
84 | val address = btPrinter.address
85 | val name = btPrinter.name
86 | PrinterWithSettingsAndInfo(
87 | printer = Printer(address = address, name = name),
88 | settings = printerSettings,
89 | info = buildPrinterInfo(id, name, printerSettings),
90 | isDefault = uuid == settings.defaultPrinter,
91 | )
92 | }.sortedBy { if (it.isDefault) 0 else 1 }
93 | return printers
94 | }
95 |
96 | private fun listNetworkPrinters(settings: Settings): List {
97 | val printers =
98 | settings.printersMap
99 | .filterValues { it.enabled && it.`interface` == Interface.TCP_IP }
100 | .mapNotNull { (uuid, printerSettings) ->
101 | val id = context.generatePrinterId(uuid)
102 | val address = uuid
103 | val name = printerSettings.name
104 | PrinterWithSettingsAndInfo(
105 | printer = Printer(address = address, name = name),
106 | settings = printerSettings,
107 | info = buildPrinterInfo(id, name, printerSettings),
108 | isDefault = uuid == settings.defaultPrinter,
109 | )
110 | }
111 | return printers
112 | }
113 |
114 | private fun listPrinters(settings: Settings): List =
115 | (this.listBluetoothPrinters(settings) + this.listNetworkPrinters(settings)).sortedBy {
116 | if (it.isDefault) 0 else 1
117 | }
118 |
119 | override fun onStopPrinterDiscovery() {
120 | job?.cancel("Printer discovery stopped")
121 | }
122 |
123 | override fun onValidatePrinters(printerIds: MutableList) {}
124 |
125 | override fun onStartPrinterStateTracking(printerId: PrinterId) {}
126 |
127 | override fun onStopPrinterStateTracking(printerId: PrinterId) {}
128 |
129 | override fun onDestroy() {}
130 | }
131 |
132 | data class PrinterWithSettingsAndInfo(
133 | val printer: Printer,
134 | val settings: PrinterSettings,
135 | val info: PrinterInfo,
136 | val isDefault: Boolean,
137 | )
138 |
139 | fun buildPrinterInfo(
140 | id: PrinterId,
141 | name: String,
142 | settings: PrinterSettings,
143 | ): PrinterInfo {
144 | val dpi = settings.dpi.coerceAtLeast(1)
145 | val width = settings.width.coerceAtLeast(0.1f)
146 | val height = settings.height.coerceAtLeast(0.1f)
147 | val df = DecimalFormat("#.#")
148 | val mediaSizeLabel = "${df.format(width)}x${df.format(height)}cm"
149 | return PrinterInfo
150 | .Builder(id, if (name == "") "no name" else name, PrinterInfo.STATUS_IDLE)
151 | .setCapabilities(
152 | PrinterCapabilitiesInfo
153 | .Builder(id)
154 | .addMediaSize(
155 | PrintAttributes.MediaSize(
156 | mediaSizeLabel,
157 | mediaSizeLabel,
158 | cmToMils(width),
159 | cmToMils(height),
160 | ),
161 | true,
162 | ).addResolution(
163 | Resolution("${dpi}dpi", "${dpi}dpi", dpi, dpi),
164 | true,
165 | ).setColorModes(
166 | PrintAttributes.COLOR_MODE_MONOCHROME,
167 | PrintAttributes.COLOR_MODE_MONOCHROME,
168 | ).setMinMargins(
169 | Margins(
170 | cmToMils(settings.marginLeft),
171 | cmToMils(settings.marginTop),
172 | cmToMils(settings.marginRight),
173 | cmToMils(settings.marginBottom),
174 | ),
175 | ).build(),
176 | ).build()
177 | }
178 |
179 | class FarminOSPrintService : PrintService() {
180 | val printersMap: MutableMap = mutableMapOf()
181 | private lateinit var session: FarminOSPrinterDiscoverySession
182 | private val serviceScope =
183 | CoroutineScope(
184 | Dispatchers.IO,
185 | )
186 |
187 | override fun onCreatePrinterDiscoverySession(): PrinterDiscoverySession {
188 | session = FarminOSPrinterDiscoverySession(this)
189 | return session
190 | }
191 |
192 | override fun onPrintJobQueued(printJob: PrintJob) {
193 | // TODO: actual queue
194 | serviceScope.launch {
195 | try {
196 | withContext(Dispatchers.Main) {
197 | printJob.start()
198 | }
199 | val info =
200 | withContext(Dispatchers.Main) {
201 | printJob.info
202 | }
203 | val document =
204 | withContext(Dispatchers.Main) {
205 | printJob.document.data
206 | }
207 | printDocument(info, document)
208 | withContext(Dispatchers.Main) {
209 | printJob.complete()
210 | }
211 | } catch (exception: Exception) {
212 | withContext(Dispatchers.Main) {
213 | printJob.fail(exception.message)
214 | }
215 | }
216 | }
217 | }
218 |
219 | private fun printDocument(
220 | info: PrintJobInfo,
221 | document: ParcelFileDescriptor?,
222 | ) {
223 | val printerId = info.printerId
224 | val printer = printersMap[printerId]
225 | if (printer == null) {
226 | throw Exception("No printer found")
227 | }
228 | if (document == null) {
229 | throw Exception("No document found")
230 | }
231 | // copy to make the file seekable
232 | val copy = copyToTmpFile(this@FarminOSPrintService.cacheDir, document.fileDescriptor)
233 | val mediaSize = info.attributes.mediaSize
234 | val resolution = info.attributes.resolution
235 | if (mediaSize == null || resolution == null) {
236 | throw Exception("No media size or resolution in print job info")
237 | }
238 | val instance = createDriver(this@FarminOSPrintService, printer.settings)
239 | try {
240 | for (i in 0 until info.copies) {
241 | instance.printDocument(copy)
242 | }
243 | } finally {
244 | // TODO: move this somewhere else
245 | instance.disconnect()
246 | }
247 | }
248 |
249 | override fun onRequestCancelPrintJob(printJob: PrintJob?) {
250 | // TODO: remove from queue or cancel if running
251 | printJob?.cancel()
252 | }
253 | }
254 |
--------------------------------------------------------------------------------
/app/src/main/java/com/farminos/print/Drivers.kt:
--------------------------------------------------------------------------------
1 | package com.farminos.print
2 |
3 | import android.bluetooth.BluetoothManager
4 | import android.content.Context
5 | import android.graphics.Bitmap
6 | import android.os.ParcelFileDescriptor
7 | import androidx.core.content.ContextCompat
8 | import com.citizen.jpos.command.CPCLConst
9 | import com.citizen.jpos.printer.CPCLPrinter
10 | import com.citizen.port.android.BluetoothPort
11 | import com.citizen.port.android.PortInterface
12 | import com.citizen.port.android.WiFiPort
13 | import com.citizen.request.android.RequestHandler
14 | import com.dantsu.escposprinter.EscPosPrinterCommands
15 | import com.dantsu.escposprinter.connection.bluetooth.BluetoothConnection
16 | import com.dantsu.escposprinter.connection.tcp.TcpConnection
17 |
18 | // TODO: make PrinterDriver Closeable
19 | abstract class PrinterDriver(
20 | context: Context,
21 | protected val settings: PrinterSettings,
22 | ) {
23 | protected var lastTime: Long? = null
24 |
25 | protected fun disconnectOnError(block: () -> Unit) {
26 | try {
27 | block()
28 | } catch (exception: Exception) {
29 | disconnect(true)
30 | throw exception
31 | }
32 | }
33 |
34 | protected fun delayForLength(cm: Float) {
35 | val now = System.currentTimeMillis()
36 | if (lastTime != null && settings.speedLimit > 0) {
37 | val elapsed = now - lastTime!!
38 | val duration = (cm / settings.speedLimit * 1000).toLong()
39 | Thread.sleep(Math.max(0, duration - elapsed))
40 | }
41 | lastTime = now
42 | }
43 |
44 | abstract fun printBitmap(bitmap: Bitmap)
45 |
46 | abstract fun disconnect(force: Boolean = false)
47 |
48 | fun printDocument(document: ParcelFileDescriptor) {
49 | pdfToBitmaps(document, settings.dpi, settings.width, settings.height).forEach { page ->
50 | val bitmap = if (settings.skipWhiteLinesAtPageEnd) bitmapCropWhiteEnd(page) else page
51 | printBitmap(bitmap)
52 | }
53 | document.close()
54 | }
55 | }
56 |
57 | class EscPosDriver(
58 | private var context: Context,
59 | settings: PrinterSettings,
60 | ) : PrinterDriver(context, settings) {
61 | private val commands: EscPosPrinterCommands
62 |
63 | private fun getBluetoothSocket(settings: PrinterSettings): BluetoothConnection {
64 | val app: OpenESCPOSPrintService = context.applicationContext as OpenESCPOSPrintService
65 | var socket: BluetoothConnection? = null
66 | if (settings.keepAlive) {
67 | socket = app.escPosBluetoothSockets[settings.address]
68 | }
69 | if (socket == null) {
70 | val bluetoothManager: BluetoothManager =
71 | ContextCompat.getSystemService(
72 | context,
73 | BluetoothManager::class.java,
74 | )!!
75 | val bluetoothAdapter = bluetoothManager.adapter
76 | val device = bluetoothAdapter.getRemoteDevice(settings.address)
77 | socket = BluetoothConnection(device)
78 | app.escPosBluetoothSockets[settings.address] = socket
79 | }
80 | return socket
81 | }
82 |
83 | private fun getTcpSocket(settings: PrinterSettings): TcpConnection {
84 | val app: OpenESCPOSPrintService = context.applicationContext as OpenESCPOSPrintService
85 | var socket: TcpConnection? = null
86 | if (settings.keepAlive) {
87 | socket = app.escPosTcpSockets[settings.name]
88 | }
89 | if (socket == null) {
90 | val addressAndPort = settings.address.split(":")
91 | socket = TcpConnection(addressAndPort[0], addressAndPort[1].toInt(), 5000)
92 | app.escPosTcpSockets[settings.name] = socket
93 | }
94 | return socket
95 | }
96 |
97 | init {
98 | val socket =
99 | when (settings.`interface`) {
100 | Interface.BLUETOOTH -> {
101 | getBluetoothSocket(settings)
102 | }
103 | Interface.TCP_IP -> {
104 | getTcpSocket(settings)
105 | }
106 | else -> {
107 | throw Exception("Unknown interface")
108 | }
109 | }
110 | if (!socket.isConnected) {
111 | socket.connect()
112 | }
113 | commands = EscPosPrinterCommands(socket)
114 | disconnectOnError {
115 | commands.connect()
116 | commands.reset()
117 | }
118 | }
119 |
120 | override fun printBitmap(bitmap: Bitmap) {
121 | val heightPx = 128
122 | bitmapSlices(bitmap, heightPx).forEach {
123 | disconnectOnError {
124 | commands.printImage(EscPosPrinterCommands.bitmapToBytes(it, true))
125 | }
126 | delayForLength(pixelsToCm(heightPx, settings.dpi))
127 | }
128 | if (settings.cut) {
129 | disconnectOnError {
130 | commands.cutPaper()
131 | }
132 | if (settings.cutDelay > 0) {
133 | Thread.sleep((settings.cutDelay * 1000).toLong())
134 | // Reset speed limit timer
135 | lastTime = System.currentTimeMillis()
136 | }
137 | }
138 | disconnectOnError {
139 | commands.reset()
140 | }
141 | }
142 |
143 | override fun disconnect(force: Boolean) {
144 | if (settings.keepAlive && !force) {
145 | return
146 | }
147 | // TODO: wait before disconnecting
148 | Thread.sleep(1000)
149 | try {
150 | commands.disconnect()
151 | } finally {
152 | val app: OpenESCPOSPrintService = context.applicationContext as OpenESCPOSPrintService
153 | when (settings.`interface`) {
154 | Interface.BLUETOOTH -> {
155 | app.escPosBluetoothSockets.remove(settings.address)
156 | }
157 |
158 | Interface.TCP_IP -> {
159 | app.escPosTcpSockets.remove(settings.name)
160 | }
161 |
162 | else -> {
163 | throw Exception("Unknown interface")
164 | }
165 | }
166 | }
167 | }
168 | }
169 |
170 | class CpclDriver(
171 | private var context: Context,
172 | settings: PrinterSettings,
173 | ) : PrinterDriver(context, settings) {
174 | private val socket: PortInterface
175 | private val requestHandlerThread: Thread
176 | private val cpclPrinter: CPCLPrinter
177 |
178 | private fun getBluetoothSocket(settings: PrinterSettings): BluetoothPort {
179 | val app: OpenESCPOSPrintService = context.applicationContext as OpenESCPOSPrintService
180 | var socket: BluetoothPort? = null
181 | if (settings.keepAlive) {
182 | socket = app.cpclBluetoothSockets[settings.address]
183 | }
184 | if (socket == null || !socket.isConnected) {
185 | socket = BluetoothPort.getInstance()
186 | socket.connect(settings.address)
187 | app.cpclBluetoothSockets[settings.address] = socket
188 | }
189 | return socket!!
190 | }
191 |
192 | private fun getTcpSocket(settings: PrinterSettings): WiFiPort {
193 | val app: OpenESCPOSPrintService = context.applicationContext as OpenESCPOSPrintService
194 | var socket: WiFiPort? = null
195 | if (settings.keepAlive) {
196 | socket = app.cpclTcpSockets[settings.name]
197 | }
198 | if (socket == null || !socket.isConnected) {
199 | socket = WiFiPort.getInstance()
200 | val addressAndPort = settings.address.split(":")
201 | socket.connect(addressAndPort[0], addressAndPort[1].toInt())
202 | app.cpclTcpSockets[settings.name] = socket
203 | }
204 | return socket!!
205 | }
206 |
207 | private fun printerCheck() {
208 | val checkStatus = cpclPrinter.printerCheck(5000)
209 | if (checkStatus != CPCLConst.CMP_SUCCESS) {
210 | throw Exception("Printer check failed: $checkStatus, please try again")
211 | }
212 | val status = cpclPrinter.status()
213 | if (status != CPCLConst.CMP_SUCCESS) {
214 | throw Exception("Printer status failed: $status, please try again")
215 | }
216 | }
217 |
218 | init {
219 | socket =
220 | when (settings.`interface`) {
221 | Interface.BLUETOOTH -> {
222 | getBluetoothSocket(settings)
223 | }
224 | Interface.TCP_IP -> {
225 | getTcpSocket(settings)
226 | }
227 | else -> {
228 | throw Exception("Unknown interface")
229 | }
230 | }
231 | while (!socket.isConnected) {
232 | Thread.sleep(100)
233 | }
234 | requestHandlerThread = Thread(RequestHandler())
235 | requestHandlerThread.start()
236 | cpclPrinter = CPCLPrinter()
237 | disconnectOnError {
238 | printerCheck()
239 | }
240 | }
241 |
242 | override fun printBitmap(bitmap: Bitmap) {
243 | delayForLength(0F)
244 | disconnectOnError {
245 | cpclPrinter.setForm(0, settings.dpi, settings.dpi, (settings.height * 100).toInt(), 1)
246 | cpclPrinter.setMedia(CPCLConst.CMP_CPCL_LABEL)
247 | }
248 | val tileSize = 36
249 | bitmapNonEmptyTiles(bitmap, tileSize).forEach {
250 | val tileBitmap = Bitmap.createBitmap(bitmap, it.x, it.y, it.width, it.height)
251 | disconnectOnError {
252 | cpclPrinter.printBitmap(tileBitmap, it.x, it.y)
253 | }
254 | }
255 | disconnectOnError {
256 | cpclPrinter.printForm()
257 | }
258 | delayForLength(settings.height)
259 | }
260 |
261 | override fun disconnect(force: Boolean) {
262 | if (requestHandlerThread.isAlive) {
263 | requestHandlerThread.interrupt()
264 | }
265 | if (settings.keepAlive && !force) {
266 | return
267 | }
268 | if (socket.isConnected) {
269 | socket.disconnect()
270 | }
271 | val app: OpenESCPOSPrintService = context.applicationContext as OpenESCPOSPrintService
272 | when (settings.`interface`) {
273 | Interface.BLUETOOTH -> {
274 | app.cpclBluetoothSockets.remove(settings.address)
275 | }
276 | Interface.TCP_IP -> {
277 | app.cpclTcpSockets.remove(settings.name)
278 | }
279 | else -> {
280 | throw Exception("Unknown interface")
281 | }
282 | }
283 | }
284 | }
285 |
286 | fun createDriver(
287 | ctx: Context,
288 | printerSettings: PrinterSettings,
289 | ): PrinterDriver {
290 | val driverClass =
291 | when (printerSettings.driver) {
292 | Driver.ESC_POS -> ::EscPosDriver
293 | Driver.CPCL -> ::CpclDriver
294 | else -> {
295 | throw Exception("Unrecognized driver in settings")
296 | }
297 | }
298 | return driverClass(ctx, printerSettings)
299 | }
300 |
--------------------------------------------------------------------------------
/app/src/main/java/com/farminos/print/PrintActivity.kt:
--------------------------------------------------------------------------------
1 | package com.farminos.print
2 |
3 | import android.Manifest
4 | import android.bluetooth.BluetoothAdapter
5 | import android.bluetooth.BluetoothManager
6 | import android.content.BroadcastReceiver
7 | import android.content.Context
8 | import android.content.Intent
9 | import android.content.IntentFilter
10 | import android.content.pm.PackageManager
11 | import android.graphics.Bitmap
12 | import android.graphics.BitmapFactory
13 | import android.net.Uri
14 | import android.os.Build
15 | import android.os.Bundle
16 | import android.util.Base64
17 | import android.webkit.WebView
18 | import android.widget.Toast
19 | import androidx.activity.ComponentActivity
20 | import androidx.activity.compose.setContent
21 | import androidx.activity.result.contract.ActivityResultContracts
22 | import androidx.compose.foundation.layout.Box
23 | import androidx.compose.foundation.layout.safeDrawingPadding
24 | import androidx.compose.ui.Modifier
25 | import androidx.core.app.ActivityCompat
26 | import androidx.core.content.ContextCompat
27 | import androidx.core.content.IntentCompat
28 | import androidx.datastore.core.CorruptionException
29 | import androidx.datastore.core.DataStore
30 | import androidx.datastore.core.Serializer
31 | import androidx.datastore.dataStore
32 | import androidx.exifinterface.media.ExifInterface
33 | import androidx.lifecycle.lifecycleScope
34 | import com.google.protobuf.InvalidProtocolBufferException
35 | import com.izettle.html2bitmap.Html2Bitmap
36 | import com.izettle.html2bitmap.Html2BitmapConfigurator
37 | import com.izettle.html2bitmap.content.WebViewContent
38 | import kotlinx.coroutines.CoroutineScope
39 | import kotlinx.coroutines.Dispatchers
40 | import kotlinx.coroutines.SupervisorJob
41 | import kotlinx.coroutines.flow.MutableStateFlow
42 | import kotlinx.coroutines.flow.first
43 | import kotlinx.coroutines.flow.update
44 | import kotlinx.coroutines.launch
45 | import org.json.JSONArray
46 | import java.io.InputStream
47 | import java.io.OutputStream
48 | import java.util.UUID
49 |
50 | object SettingsSerializer : Serializer {
51 | override val defaultValue: Settings = Settings.getDefaultInstance()
52 |
53 | override suspend fun readFrom(input: InputStream): Settings {
54 | try {
55 | return Settings.parseFrom(input)
56 | } catch (exception: InvalidProtocolBufferException) {
57 | throw CorruptionException("Cannot read proto.", exception)
58 | }
59 | }
60 |
61 | override suspend fun writeTo(
62 | t: Settings,
63 | output: OutputStream,
64 | ) = t.writeTo(output)
65 | }
66 |
67 | val Context.settingsDataStore: DataStore by dataStore(
68 | fileName = "settings.pb",
69 | serializer = SettingsSerializer,
70 | )
71 |
72 | class PrintActivity : ComponentActivity() {
73 | private val bluetoothBroadcastReceiver = BluetoothBroadcastReceiver(this)
74 | private val appCoroutineScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
75 | private val activityResultLauncher =
76 | registerForActivityResult(
77 | ActivityResultContracts.StartActivityForResult(),
78 | ) {
79 | updatePrintersList()
80 | }
81 | private val requestPermissionLauncher =
82 | registerForActivityResult(
83 | ActivityResultContracts.RequestMultiplePermissions(),
84 | ) {
85 | updatePrintersList()
86 | }
87 | var bluetoothAllowed: MutableStateFlow = MutableStateFlow(false)
88 | var bluetoothEnabled: MutableStateFlow = MutableStateFlow(false)
89 |
90 | fun updateDefaultPrinter(address: String) {
91 | appCoroutineScope.launch {
92 | this@PrintActivity.settingsDataStore.updateData { currentSettings ->
93 | currentSettings
94 | .toBuilder()
95 | .setDefaultPrinter(address)
96 | .build()
97 | }
98 | }
99 | }
100 |
101 | fun updatePrinterSetting(
102 | uuid: String,
103 | updater: (ps: PrinterSettings.Builder) -> PrinterSettings.Builder,
104 | ) {
105 | appCoroutineScope.launch {
106 | this@PrintActivity.settingsDataStore.updateData { currentSettings ->
107 | val builder = currentSettings.toBuilder()
108 | val printerBuilder = (builder.printersMap[uuid] ?: PrinterSettings.getDefaultInstance()).toBuilder()
109 | builder.putPrinters(uuid, updater(printerBuilder).build())
110 | return@updateData builder.build()
111 | }
112 | }
113 | }
114 |
115 | fun addPrinterSetting() {
116 | appCoroutineScope.launch {
117 | this@PrintActivity.settingsDataStore.updateData { currentSettings ->
118 | val uuid = UUID.randomUUID().toString()
119 | val builder = currentSettings.toBuilder()
120 | builder.putPrinters(
121 | uuid,
122 | DEFAULT_PRINTER_SETTINGS.toBuilder().setInterface(Interface.TCP_IP).build(),
123 | )
124 | return@updateData builder.build()
125 | }
126 | }
127 | }
128 |
129 | fun deletePrinterSetting(uuid: String) {
130 | appCoroutineScope.launch {
131 | this@PrintActivity.settingsDataStore.updateData { currentSettings ->
132 | val builder = currentSettings.toBuilder()
133 | builder.removePrinters(uuid)
134 | if (uuid == builder.defaultPrinter) {
135 | builder.defaultPrinter = ""
136 | }
137 | return@updateData builder.build()
138 | }
139 | }
140 | }
141 |
142 | fun printTestPage(uuid: String) {
143 | val pages = JSONArray()
144 | pages.put("\uD83D\uDDA8️
")
145 | lifecycleScope.launch(Dispatchers.IO) {
146 | runOrToast {
147 | printHtml(pages, uuid)
148 | }
149 | }
150 | }
151 |
152 | private fun updatePrintersList() {
153 | val allowed =
154 | if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
155 | ActivityCompat.checkSelfPermission(
156 | this,
157 | Manifest.permission.BLUETOOTH_CONNECT,
158 | ) == PackageManager.PERMISSION_GRANTED
159 | } else {
160 | true
161 | }
162 | bluetoothAllowed.update { allowed }
163 | if (!allowed) {
164 | return
165 | }
166 | val bluetoothManager =
167 | ContextCompat.getSystemService(this, BluetoothManager::class.java)
168 | ?: return
169 | val bluetoothAdapter = bluetoothManager.adapter ?: return
170 | bluetoothEnabled.update {
171 | bluetoothAdapter.isEnabled
172 | }
173 | lifecycleScope.launch {
174 | bluetoothAdapter.bondedDevices
175 | .filter { it.bluetoothClass.deviceClass == 1664 } // 1664 is major 0x600 (IMAGING) + minor 0x80 (PRINTER)
176 | .forEach {
177 | this@PrintActivity.settingsDataStore.updateData { currentSettings ->
178 | val builder = currentSettings.toBuilder()
179 | if (!currentSettings.printersMap.contains(it.address)) {
180 | val newPrinter =
181 | DEFAULT_PRINTER_SETTINGS
182 | .toBuilder()
183 | .setInterface(Interface.BLUETOOTH)
184 | .setAddress(it.address)
185 | .setName(it.name)
186 | .setDriver(if (it.name.startsWith("CMP_")) Driver.CPCL else Driver.ESC_POS)
187 | .setKeepAlive(it.name.startsWith("CMP_")) // Keep connections alive by default for Citizen printers
188 | .build()
189 | builder.putPrinters(it.address, newPrinter)
190 | }
191 | return@updateData builder.build()
192 | }
193 | }
194 | }
195 | }
196 |
197 | private suspend fun runOrToast(block: suspend () -> Unit) {
198 | try {
199 | block()
200 | } catch (exception: Exception) {
201 | exception.printStackTrace()
202 | val message = exception.message ?: exception.toString()
203 | this@PrintActivity.runOnUiThread {
204 | Toast.makeText(this@PrintActivity, message, Toast.LENGTH_SHORT).show()
205 | }
206 | }
207 | }
208 |
209 | override fun onResume() {
210 | super.onResume()
211 | updatePrintersList()
212 | }
213 |
214 | override fun onCreate(savedInstanceState: Bundle?) {
215 | super.onCreate(savedInstanceState)
216 | updatePrintersList()
217 |
218 | registerReceiver(
219 | bluetoothBroadcastReceiver,
220 | IntentFilter(BluetoothAdapter.ACTION_STATE_CHANGED),
221 | )
222 |
223 | when {
224 | intent.action.equals(Intent.ACTION_VIEW) -> {
225 | val content: String? = intent.getStringExtra("content")
226 | if (content == null) {
227 | Toast
228 | .makeText(this, "No content provided for printing", Toast.LENGTH_SHORT)
229 | .show()
230 | finish()
231 | } else {
232 | val pages: JSONArray? =
233 | try {
234 | JSONArray(decompress(Base64.decode(content, Base64.DEFAULT)))
235 | } catch (exception: Exception) {
236 | Toast
237 | .makeText(this, "Could not decode url: ${exception.message}", Toast.LENGTH_SHORT)
238 | .show()
239 | null
240 | }
241 | if (pages == null) {
242 | finish()
243 | return
244 | }
245 | lifecycleScope.launch(Dispatchers.IO) {
246 | runOrToast {
247 | printHtml(pages)
248 | }
249 | finish()
250 | }
251 | }
252 | }
253 | intent.action.equals(Intent.ACTION_SEND) && intent.type?.startsWith("image/") == true -> {
254 | lifecycleScope.launch(Dispatchers.IO) {
255 | handleSendImage(intent)
256 | finish()
257 | }
258 | }
259 | intent.action.equals(Intent.ACTION_SEND_MULTIPLE) && intent.type?.startsWith("image/") == true -> {
260 | lifecycleScope.launch(Dispatchers.IO) {
261 | handleSendMultipleImages(intent)
262 | finish()
263 | }
264 | }
265 | else -> {
266 | setContent {
267 | Box(Modifier.safeDrawingPadding()) {
268 | SettingsScreen(context = this@PrintActivity)
269 | }
270 | }
271 | }
272 | }
273 | }
274 |
275 | private suspend fun handleSendImage(intent: Intent) {
276 | IntentCompat.getParcelableExtra(intent, Intent.EXTRA_STREAM, Uri::class.java)?.let {
277 | runOrToast {
278 | printBitmaps(arrayListOf(it))
279 | }
280 | }
281 | }
282 |
283 | private suspend fun handleSendMultipleImages(intent: Intent) {
284 | IntentCompat.getParcelableArrayListExtra(intent, Intent.EXTRA_STREAM, Uri::class.java)?.let {
285 | runOrToast {
286 | printBitmaps(it)
287 | }
288 | }
289 | }
290 |
291 | private suspend fun printBitmaps(uris: ArrayList) {
292 | val settings = settingsDataStore.data.first()
293 | val uuid = settings.defaultPrinter
294 | if (uuid == "" || uuid == null) {
295 | throw Exception("Please configure a default printer.")
296 | }
297 | val printerSettings = settings.printersMap[uuid]
298 | if (printerSettings == null) {
299 | throw Exception("Could not find printer settings.")
300 | }
301 | val instance = createDriver(this, printerSettings)
302 | try {
303 | uris.forEach {
304 | val orientation =
305 | contentResolver.openInputStream(it)?.use { inputStream ->
306 | ExifInterface(inputStream).getAttributeInt(ExifInterface.TAG_ORIENTATION, ExifInterface.ORIENTATION_UNDEFINED)
307 | } ?: ExifInterface.ORIENTATION_UNDEFINED
308 | contentResolver.openInputStream(it)?.use { inputStream ->
309 | val bitmap = BitmapFactory.decodeStream(inputStream)
310 | val scaledBitmap = scaleBitmap(rotateBitmap(bitmap, orientation), printerSettings)
311 | instance.printBitmap(scaledBitmap)
312 | }
313 | }
314 | } finally {
315 | instance.disconnect()
316 | }
317 | }
318 |
319 | private suspend fun printHtml(
320 | pages: JSONArray,
321 | printerUuid: String? = null,
322 | ) {
323 | val settings = settingsDataStore.data.first()
324 | val uuid = printerUuid ?: settings.defaultPrinter
325 | if (uuid == "" || uuid == null) {
326 | throw Exception("Please configure a default printer.")
327 | }
328 | val printerSettings = settings.printersMap[uuid]
329 | if (printerSettings == null) {
330 | throw Exception("Could not find printer settings.")
331 | }
332 | val width = printerSettings.width
333 | val marginLeft = printerSettings.marginLeft
334 | val marginTop = printerSettings.marginTop
335 | val marginRight = printerSettings.marginRight
336 | val marginBottom = printerSettings.marginBottom
337 | val dpi = printerSettings.dpi
338 | val instance = createDriver(this, printerSettings)
339 | try {
340 | renderPages(
341 | this,
342 | width,
343 | dpi,
344 | pages,
345 | marginLeft,
346 | marginTop,
347 | marginRight,
348 | marginBottom,
349 | ).forEach {
350 | instance.printBitmap(it)
351 | }
352 | } finally {
353 | instance.disconnect()
354 | }
355 | }
356 |
357 | override fun onDestroy() {
358 | super.onDestroy()
359 | unregisterReceiver(bluetoothBroadcastReceiver)
360 | }
361 |
362 | fun requestBluetoothPermissions() {
363 | if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
364 | requestPermissionLauncher.launch(
365 | arrayOf(Manifest.permission.BLUETOOTH_CONNECT, Manifest.permission.BLUETOOTH_SCAN),
366 | )
367 | }
368 | }
369 |
370 | fun enableBluetooth() {
371 | val intent = Intent(BluetoothAdapter.ACTION_REQUEST_ENABLE)
372 | activityResultLauncher.launch(intent)
373 | }
374 |
375 | private class BluetoothBroadcastReceiver(
376 | _context: PrintActivity,
377 | ) : BroadcastReceiver() {
378 | val activity = _context
379 |
380 | override fun onReceive(
381 | context: Context,
382 | intent: Intent,
383 | ) {
384 | val action = intent.action
385 |
386 | if (action.equals(BluetoothAdapter.ACTION_STATE_CHANGED)) {
387 | val state = intent.getIntExtra(BluetoothAdapter.EXTRA_STATE, BluetoothAdapter.ERROR)
388 |
389 | if (state == BluetoothAdapter.STATE_ON) {
390 | activity.bluetoothEnabled.update {
391 | true
392 | }
393 | Toast.makeText(context, "bluetooth is on.", Toast.LENGTH_SHORT).show()
394 | }
395 | if (state == BluetoothAdapter.STATE_OFF) {
396 | activity.bluetoothEnabled.update {
397 | false
398 | }
399 | Toast.makeText(context, "bluetooth is off.", Toast.LENGTH_SHORT).show()
400 | }
401 | }
402 | }
403 | }
404 | }
405 |
406 | private class Configurator : Html2BitmapConfigurator() {
407 | override fun configureWebView(webview: WebView?) {
408 | super.configureWebView(webview)
409 | webview?.settings?.defaultTextEncodingName = "utf-8"
410 | }
411 | }
412 |
413 | private fun renderHtml(
414 | context: Context,
415 | widthPixels: Int,
416 | content: String,
417 | ): Bitmap? =
418 | Html2Bitmap
419 | .Builder()
420 | .setContext(context)
421 | .setConfigurator(Configurator())
422 | .setBitmapWidth(widthPixels)
423 | .setContent(WebViewContent.html(content))
424 | .setScreenshotDelay(0)
425 | .setMeasureDelay(0)
426 | .build()
427 | .bitmap
428 |
429 | private fun renderPages(
430 | context: Context,
431 | width: Float,
432 | dpi: Int,
433 | pages: JSONArray,
434 | marginLeft: Float,
435 | marginTop: Float,
436 | marginRight: Float,
437 | marginBottom: Float,
438 | ) = sequence {
439 | val widthPx = cmToPixels(width, dpi)
440 | val marginLeftPx = cmToPixels(marginLeft, dpi)
441 | val marginTopPx = cmToPixels(marginTop, dpi)
442 | val marginRightPx = cmToPixels(marginRight, dpi)
443 | val marginBottomPx = cmToPixels(marginBottom, dpi)
444 | val renderWidthPx = widthPx - marginLeftPx - marginRightPx
445 | for (i in 0 until pages.length()) {
446 | val page = pages.getString(i)
447 | val bitmap = renderHtml(context, renderWidthPx, page)
448 | if (bitmap != null) {
449 | if (marginLeftPx == 0 && marginTopPx == 0 && marginRightPx == 0 && marginBottomPx == 0) {
450 | yield(bitmap)
451 | } else {
452 | yield(addMargins(bitmap, marginLeftPx, marginTopPx, marginRightPx, marginBottomPx))
453 | }
454 | }
455 | }
456 | }
457 |
--------------------------------------------------------------------------------
/app/src/main/java/com/farminos/print/Ui.kt:
--------------------------------------------------------------------------------
1 | package com.farminos.print
2 |
3 | import android.content.Intent
4 | import android.provider.Settings.ACTION_BLUETOOTH_SETTINGS
5 | import androidx.compose.animation.animateContentSize
6 | import androidx.compose.animation.core.LinearOutSlowInEasing
7 | import androidx.compose.animation.core.animateFloatAsState
8 | import androidx.compose.animation.core.tween
9 | import androidx.compose.foundation.clickable
10 | import androidx.compose.foundation.layout.Arrangement
11 | import androidx.compose.foundation.layout.Box
12 | import androidx.compose.foundation.layout.Column
13 | import androidx.compose.foundation.layout.Row
14 | import androidx.compose.foundation.layout.fillMaxSize
15 | import androidx.compose.foundation.layout.fillMaxWidth
16 | import androidx.compose.foundation.layout.padding
17 | import androidx.compose.foundation.rememberScrollState
18 | import androidx.compose.foundation.text.KeyboardOptions
19 | import androidx.compose.foundation.verticalScroll
20 | import androidx.compose.material3.Button
21 | import androidx.compose.material3.ButtonDefaults
22 | import androidx.compose.material3.Card
23 | import androidx.compose.material3.DropdownMenu
24 | import androidx.compose.material3.DropdownMenuItem
25 | import androidx.compose.material3.ExperimentalMaterial3Api
26 | import androidx.compose.material3.Icon
27 | import androidx.compose.material3.IconButton
28 | import androidx.compose.material3.MaterialTheme
29 | import androidx.compose.material3.Surface
30 | import androidx.compose.material3.Switch
31 | import androidx.compose.material3.Text
32 | import androidx.compose.material3.TextField
33 | import androidx.compose.runtime.Composable
34 | import androidx.compose.runtime.collectAsState
35 | import androidx.compose.runtime.getValue
36 | import androidx.compose.runtime.mutableStateOf
37 | import androidx.compose.runtime.remember
38 | import androidx.compose.runtime.setValue
39 | import androidx.compose.ui.Alignment
40 | import androidx.compose.ui.Modifier
41 | import androidx.compose.ui.draw.rotate
42 | import androidx.compose.ui.res.painterResource
43 | import androidx.compose.ui.text.input.KeyboardType
44 | import androidx.compose.ui.unit.dp
45 | import androidx.core.content.ContextCompat.startActivity
46 | import com.farminos.print.ui.theme.OpenESCPOSPrintServiceTheme
47 |
48 | @OptIn(ExperimentalMaterial3Api::class)
49 | @Composable
50 | fun ExpandableCard(
51 | header: @Composable () -> Unit,
52 | content: @Composable () -> Unit,
53 | ) {
54 | var open by remember { mutableStateOf(false) }
55 | val rotation by animateFloatAsState(
56 | targetValue = if (open) 180f else 0f,
57 | )
58 |
59 | Card(
60 | modifier =
61 | Modifier
62 | .fillMaxWidth()
63 | .padding(10.dp)
64 | .animateContentSize(
65 | animationSpec =
66 | tween(
67 | durationMillis = 300,
68 | easing = LinearOutSlowInEasing,
69 | ),
70 | ),
71 | onClick = {
72 | open = !open
73 | },
74 | ) {
75 | Column(
76 | modifier =
77 | Modifier
78 | .fillMaxWidth()
79 | .padding(10.dp),
80 | ) {
81 | Row(
82 | modifier = Modifier.fillMaxWidth(),
83 | horizontalArrangement = Arrangement.SpaceBetween,
84 | ) {
85 | header()
86 | IconButton(
87 | modifier = Modifier.rotate(rotation),
88 | onClick = {
89 | open = !open
90 | },
91 | ) {
92 | Icon(
93 | painter = painterResource(id = R.drawable.arrow_drop_down_24px),
94 | contentDescription = "drop down arrow",
95 | )
96 | }
97 | }
98 | if (open) {
99 | content()
100 | }
101 | }
102 | }
103 | }
104 |
105 | data class Option(
106 | val value: Int,
107 | val label: String,
108 | )
109 |
110 | @OptIn(ExperimentalMaterial3Api::class)
111 | @Composable
112 | fun MenuSelect(
113 | options: Array