├── 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