diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml new file mode 100644 index 0000000..e86d86a --- /dev/null +++ b/.github/workflows/pages.yml @@ -0,0 +1,37 @@ +name: Deploy Image Prep to GitHub Pages + +on: + push: + branches: [main] + paths: + - "web-image-prep/**" + - ".github/workflows/pages.yml" + workflow_dispatch: + +permissions: + contents: read + pages: write + id-token: write + +concurrency: + group: "pages" + cancel-in-progress: false + +jobs: + deploy: + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + - name: Configure Pages + uses: actions/configure-pages@v5 + - name: Upload artifact + uses: actions/upload-pages-artifact@v3 + with: + path: web-image-prep + - name: Deploy + id: deployment + uses: actions/deploy-pages@v4 diff --git a/README.md b/README.md index 4b21b87..116c962 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ ## Overview -TagTinker is a Flipper Zero app and Android Companion for exploring infrared electronic shelf-label (ESL) protocols. It allows you to transmit custom images and text to supported graphics tags. +TagTinker is a Flipper Zero app for exploring infrared electronic shelf-label (ESL) protocols. It allows you to transmit custom images and text to supported graphics tags. A companion **web image preparer** runs entirely in the browser and lets you drop, dither and download Flipper-ready BMPs without any install. As the Flipper Zero team notes: > "FYI: this is pure infrared signal, same that you use in TV remotes. The whole security was relying on obscurity of protocol." @@ -28,18 +28,18 @@ This tool is built for IoT security curiosity, learning about obscure protocols, ## Features - **TagTinker Flipper App:** High-performance, zero-allocation RLE streaming IR engine. -- **TagTinker Android Companion:** Edit and dither images directly on your phone and sync them instantly to the Flipper Zero over BLE. +- **TagTinker Image Prep (web):** Single-file, dependency-free HTML page that lists every supported tag profile, runs a full image pipeline (tone, contrast, detail, sharpen, dither, photo-grade Oklab 3-colour quantisation) and exports a Flipper-ready BMP. Hosted at **[i12bp8.github.io/TagTinker](https://i12bp8.github.io/TagTinker/)** (source: `web-image-prep/`). +- **Drop-folder image flow:** Drop a prepared BMP into `apps_data/tagtinker/dropped/` on the Flipper SD card, then open `Targeted Payloads → → Set Image` and pick it. The Flipper rescales any BMP on the fly so a single file can target any tag and any page. - **NFC Tag Scan:** Instantly identify ESL targets by scanning their NFC tag — no manual barcode entry needed. - Display text, custom images, and test-patterns. - Support for monochrome and accent-color (red/yellow) graphics tags. ## Getting Started -The TagTinker Android app manages preparing display payloads and uploading them to the Flipper over BLE. -1. Build the Flipper app from this repository and install it via `ufbt`. -2. Download the pre-built Android Companion APK from the [Releases](https://github.com/i12bp8/TagTinker/releases) page and install it on your device. -3. Open the Flipper app and go to **Phone Sync (Custom Images)**. -4. Use the Android app to connect, prepare an image, and send it directly to the Flipper for transmission. +1. Build the Flipper app from this repository and install it via `ufbt`. The first launch creates `apps_data/tagtinker/dropped/` on your SD card. +2. Open **[i12bp8.github.io/TagTinker](https://i12bp8.github.io/TagTinker/)** in any browser, pick your tag profile, drop an image, tweak, and download the BMP. +3. Copy the BMP into `apps_data/tagtinker/dropped/` on the SD card (over `qFlipper`, USB MTP, or whatever you use). +4. On the Flipper open `Targeted Payloads → → Set Image`, pick the BMP, choose a page, send. ## FAQ diff --git a/android-companion/.gradle/4.10.1/fileChanges/last-build.bin b/android-companion/.gradle/4.10.1/fileChanges/last-build.bin deleted file mode 100644 index f76dd23..0000000 Binary files a/android-companion/.gradle/4.10.1/fileChanges/last-build.bin and /dev/null differ diff --git a/android-companion/.gradle/4.10.1/fileHashes/fileHashes.bin b/android-companion/.gradle/4.10.1/fileHashes/fileHashes.bin deleted file mode 100644 index 036d9b6..0000000 Binary files a/android-companion/.gradle/4.10.1/fileHashes/fileHashes.bin and /dev/null differ diff --git a/android-companion/.gradle/4.10.1/fileHashes/fileHashes.lock b/android-companion/.gradle/4.10.1/fileHashes/fileHashes.lock deleted file mode 100644 index d6bc2f1..0000000 Binary files a/android-companion/.gradle/4.10.1/fileHashes/fileHashes.lock and /dev/null differ diff --git a/android-companion/.gradle/4.10.1/gc.properties b/android-companion/.gradle/4.10.1/gc.properties deleted file mode 100644 index e69de29..0000000 diff --git a/android-companion/.gradle/8.11.1/checksums/checksums.lock b/android-companion/.gradle/8.11.1/checksums/checksums.lock deleted file mode 100644 index c075db0..0000000 Binary files a/android-companion/.gradle/8.11.1/checksums/checksums.lock and /dev/null differ diff --git a/android-companion/.gradle/8.11.1/checksums/md5-checksums.bin b/android-companion/.gradle/8.11.1/checksums/md5-checksums.bin deleted file mode 100644 index 08415cd..0000000 Binary files a/android-companion/.gradle/8.11.1/checksums/md5-checksums.bin and /dev/null differ diff --git a/android-companion/.gradle/8.11.1/checksums/sha1-checksums.bin b/android-companion/.gradle/8.11.1/checksums/sha1-checksums.bin deleted file mode 100644 index a146156..0000000 Binary files a/android-companion/.gradle/8.11.1/checksums/sha1-checksums.bin and /dev/null differ diff --git a/android-companion/.gradle/8.11.1/executionHistory/executionHistory.bin b/android-companion/.gradle/8.11.1/executionHistory/executionHistory.bin deleted file mode 100644 index 97761d2..0000000 Binary files a/android-companion/.gradle/8.11.1/executionHistory/executionHistory.bin and /dev/null differ diff --git a/android-companion/.gradle/8.11.1/executionHistory/executionHistory.lock b/android-companion/.gradle/8.11.1/executionHistory/executionHistory.lock deleted file mode 100644 index d2b584d..0000000 Binary files a/android-companion/.gradle/8.11.1/executionHistory/executionHistory.lock and /dev/null differ diff --git a/android-companion/.gradle/8.11.1/fileChanges/last-build.bin b/android-companion/.gradle/8.11.1/fileChanges/last-build.bin deleted file mode 100644 index f76dd23..0000000 Binary files a/android-companion/.gradle/8.11.1/fileChanges/last-build.bin and /dev/null differ diff --git a/android-companion/.gradle/8.11.1/fileHashes/fileHashes.bin b/android-companion/.gradle/8.11.1/fileHashes/fileHashes.bin deleted file mode 100644 index af0d026..0000000 Binary files a/android-companion/.gradle/8.11.1/fileHashes/fileHashes.bin and /dev/null differ diff --git a/android-companion/.gradle/8.11.1/fileHashes/fileHashes.lock b/android-companion/.gradle/8.11.1/fileHashes/fileHashes.lock deleted file mode 100644 index 00dacea..0000000 Binary files a/android-companion/.gradle/8.11.1/fileHashes/fileHashes.lock and /dev/null differ diff --git a/android-companion/.gradle/8.11.1/fileHashes/resourceHashesCache.bin b/android-companion/.gradle/8.11.1/fileHashes/resourceHashesCache.bin deleted file mode 100644 index 5ed2074..0000000 Binary files a/android-companion/.gradle/8.11.1/fileHashes/resourceHashesCache.bin and /dev/null differ diff --git a/android-companion/.gradle/8.11.1/gc.properties b/android-companion/.gradle/8.11.1/gc.properties deleted file mode 100644 index e69de29..0000000 diff --git a/android-companion/.gradle/buildOutputCleanup/buildOutputCleanup.lock b/android-companion/.gradle/buildOutputCleanup/buildOutputCleanup.lock deleted file mode 100644 index afab74c..0000000 Binary files a/android-companion/.gradle/buildOutputCleanup/buildOutputCleanup.lock and /dev/null differ diff --git a/android-companion/.gradle/buildOutputCleanup/cache.properties b/android-companion/.gradle/buildOutputCleanup/cache.properties deleted file mode 100644 index adae4b0..0000000 --- a/android-companion/.gradle/buildOutputCleanup/cache.properties +++ /dev/null @@ -1,2 +0,0 @@ -#Thu Apr 23 20:33:11 CEST 2026 -gradle.version=8.11.1 diff --git a/android-companion/.gradle/buildOutputCleanup/outputFiles.bin b/android-companion/.gradle/buildOutputCleanup/outputFiles.bin deleted file mode 100644 index 7fa1eff..0000000 Binary files a/android-companion/.gradle/buildOutputCleanup/outputFiles.bin and /dev/null differ diff --git a/android-companion/.gradle/file-system.probe b/android-companion/.gradle/file-system.probe deleted file mode 100644 index cf01608..0000000 Binary files a/android-companion/.gradle/file-system.probe and /dev/null differ diff --git a/android-companion/.gradle/kotlin/errors/errors-1777004057193.log b/android-companion/.gradle/kotlin/errors/errors-1777004057193.log deleted file mode 100644 index 1219b50..0000000 --- a/android-companion/.gradle/kotlin/errors/errors-1777004057193.log +++ /dev/null @@ -1,4 +0,0 @@ -kotlin version: 2.0.21 -error message: The daemon has terminated unexpectedly on startup attempt #1 with error code: 0. The daemon process output: - 1. Kotlin compile daemon is ready - diff --git a/android-companion/.gradle/vcs-1/gc.properties b/android-companion/.gradle/vcs-1/gc.properties deleted file mode 100644 index e69de29..0000000 diff --git a/android-companion/.kotlin/errors/errors-1777004057193.log b/android-companion/.kotlin/errors/errors-1777004057193.log deleted file mode 100644 index 1219b50..0000000 --- a/android-companion/.kotlin/errors/errors-1777004057193.log +++ /dev/null @@ -1,4 +0,0 @@ -kotlin version: 2.0.21 -error message: The daemon has terminated unexpectedly on startup attempt #1 with error code: 0. The daemon process output: - 1. Kotlin compile daemon is ready - diff --git a/android-companion/app/build.gradle.kts b/android-companion/app/build.gradle.kts deleted file mode 100644 index 0418edb..0000000 --- a/android-companion/app/build.gradle.kts +++ /dev/null @@ -1,95 +0,0 @@ -plugins { - id("com.android.application") - id("org.jetbrains.kotlin.android") - id("org.jetbrains.kotlin.plugin.compose") -} - -android { - namespace = "xyz.i12bp8.tagtinker.android" - compileSdk = 35 - - defaultConfig { - applicationId = "xyz.i12bp8.tagtinker.android" - minSdk = 29 - targetSdk = 35 - versionCode = 1 - versionName = "2.0" - - testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" - vectorDrawables.useSupportLibrary = true - } - - signingConfigs { - create("release") { - storeFile = file("tagtinker.keystore") - storePassword = "tagtinker" - keyAlias = "tagtinker" - keyPassword = "tagtinker" - } - } - - buildTypes { - release { - isMinifyEnabled = false - proguardFiles( - getDefaultProguardFile("proguard-android-optimize.txt"), - "proguard-rules.pro", - ) - signingConfig = signingConfigs.getByName("release") - } - } - - compileOptions { - sourceCompatibility = JavaVersion.VERSION_17 - targetCompatibility = JavaVersion.VERSION_17 - } - - kotlinOptions { - jvmTarget = "17" - } - - buildFeatures { - compose = true - } - - packaging { - resources { - excludes += "/META-INF/{AL2.0,LGPL2.1}" - } - } -} - -dependencies { - val composeBom = platform("androidx.compose:compose-bom:2025.04.01") - - implementation(composeBom) - androidTestImplementation(composeBom) - - implementation("androidx.core:core-ktx:1.16.0") - implementation("androidx.appcompat:appcompat:1.7.0") - implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.8.7") - implementation("androidx.lifecycle:lifecycle-runtime-compose:2.8.7") - implementation("androidx.activity:activity-compose:1.10.1") - implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.8.7") - implementation("androidx.navigation:navigation-compose:2.8.9") - implementation("androidx.compose.ui:ui") - implementation("androidx.compose.ui:ui-graphics") - implementation("androidx.compose.ui:ui-tooling-preview") - implementation("androidx.compose.material3:material3:1.3.2") - implementation("androidx.compose.material:material-icons-extended") - implementation("androidx.camera:camera-core:1.4.2") - implementation("androidx.camera:camera-camera2:1.4.2") - implementation("androidx.camera:camera-lifecycle:1.4.2") - implementation("androidx.camera:camera-view:1.4.2") - implementation("com.google.mlkit:barcode-scanning:17.3.0") - implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0") - implementation("no.nordicsemi.android.kotlin.ble:scanner:1.3.1") - implementation("no.nordicsemi.android.kotlin.ble:client:1.3.1") - - testImplementation("junit:junit:4.13.2") - androidTestImplementation("androidx.test.ext:junit:1.2.1") - androidTestImplementation("androidx.test.espresso:espresso-core:3.6.1") - androidTestImplementation("androidx.compose.ui:ui-test-junit4") - debugImplementation("androidx.compose.ui:ui-tooling") - debugImplementation("androidx.compose.ui:ui-test-manifest") -} diff --git a/android-companion/app/proguard-rules.pro b/android-companion/app/proguard-rules.pro deleted file mode 100644 index 8b13789..0000000 --- a/android-companion/app/proguard-rules.pro +++ /dev/null @@ -1 +0,0 @@ - diff --git a/android-companion/app/src/main/AndroidManifest.xml b/android-companion/app/src/main/AndroidManifest.xml deleted file mode 100644 index ccbfcaf..0000000 --- a/android-companion/app/src/main/AndroidManifest.xml +++ /dev/null @@ -1,34 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/android-companion/app/src/main/java/xyz/i12bp8/tagtinker/android/MainActivity.kt b/android-companion/app/src/main/java/xyz/i12bp8/tagtinker/android/MainActivity.kt deleted file mode 100644 index ad1deab..0000000 --- a/android-companion/app/src/main/java/xyz/i12bp8/tagtinker/android/MainActivity.kt +++ /dev/null @@ -1,1052 +0,0 @@ -package xyz.i12bp8.tagtinker.android - -import android.Manifest -import android.app.Activity -import android.bluetooth.BluetoothDevice -import android.util.Log -import android.companion.AssociationRequest -import android.companion.BluetoothLeDeviceFilter -import android.companion.CompanionDeviceManager -import android.content.pm.PackageManager -import android.graphics.Bitmap -import android.os.Build -import android.os.Bundle -import android.util.Size -import androidx.activity.ComponentActivity -import androidx.activity.compose.rememberLauncherForActivityResult -import androidx.activity.compose.setContent -import androidx.activity.enableEdgeToEdge -import androidx.activity.result.IntentSenderRequest -import androidx.activity.result.PickVisualMediaRequest -import androidx.activity.result.contract.ActivityResultContracts -import androidx.activity.viewModels -import androidx.camera.core.CameraSelector -import androidx.camera.core.ExperimentalGetImage -import androidx.camera.core.ImageAnalysis -import androidx.camera.core.ImageProxy -import androidx.camera.lifecycle.ProcessCameraProvider -import androidx.camera.view.PreviewView -import androidx.compose.foundation.Canvas -import androidx.compose.foundation.Image -import androidx.compose.foundation.background -import androidx.compose.foundation.border -import androidx.compose.foundation.gestures.detectTransformGestures -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.ColumnScope -import androidx.compose.foundation.layout.ExperimentalLayoutApi -import androidx.compose.foundation.layout.FlowRow -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.aspectRatio -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.navigationBarsPadding -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.statusBarsPadding -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.verticalScroll -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.rounded.Bluetooth -import androidx.compose.material.icons.rounded.Collections -import androidx.compose.material.icons.rounded.History -import androidx.compose.material.icons.rounded.Image -import androidx.compose.material.icons.rounded.Send -import androidx.compose.material.icons.rounded.Sync -import androidx.compose.material3.Button -import androidx.compose.material3.Card -import androidx.compose.material3.CardDefaults -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.FilterChip -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.LinearProgressIndicator -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.ModalBottomSheet -import androidx.compose.material3.NavigationBar -import androidx.compose.material3.NavigationBarItem -import androidx.compose.material3.OutlinedButton -import androidx.compose.material3.Scaffold -import androidx.compose.material3.Slider -import androidx.compose.material3.Surface -import androidx.compose.material3.Switch -import androidx.compose.material3.Text -import androidx.compose.material3.TextButton -import androidx.compose.material3.darkColorScheme -import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableFloatStateOf -import androidx.compose.runtime.mutableIntStateOf -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberUpdatedState -import androidx.compose.runtime.saveable.rememberSaveable -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.graphicsLayer -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.asImageBitmap -import androidx.compose.ui.graphics.drawscope.Stroke -import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.input.pointer.pointerInput -import androidx.compose.ui.layout.onSizeChanged -import androidx.compose.ui.unit.IntSize -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.platform.LocalLifecycleOwner -import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.unit.dp -import androidx.compose.ui.viewinterop.AndroidView -import androidx.core.content.ContextCompat -import java.util.regex.Pattern -import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.google.mlkit.vision.barcode.BarcodeScannerOptions -import com.google.mlkit.vision.barcode.BarcodeScanning -import com.google.mlkit.vision.barcode.common.Barcode -import com.google.mlkit.vision.common.InputImage -import java.text.DateFormat -import java.util.Date -import java.util.concurrent.Executors -import java.util.concurrent.atomic.AtomicBoolean -import kotlin.OptIn -import xyz.i12bp8.tagtinker.android.model.BlePeripheral -import xyz.i12bp8.tagtinker.android.model.DitherMode -import xyz.i12bp8.tagtinker.android.model.EditorState -import xyz.i12bp8.tagtinker.android.model.FitMode -import xyz.i12bp8.tagtinker.android.model.PayloadHistoryItem - -class MainActivity : ComponentActivity() { - private val viewModel by viewModels() - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - enableEdgeToEdge() - setContent { - TagTinkerTheme { - AppRoot(viewModel = viewModel) - } - } - } -} - -@Composable -private fun TagTinkerTheme(content: @Composable () -> Unit) { - val colors = darkColorScheme( - primary = Color(0xFF5D8DFF), - onPrimary = Color(0xFF04101F), - secondary = Color(0xFF8B78FF), - background = Color(0xFF040507), - surface = Color(0xFF0B0E13), - surfaceVariant = Color(0xFF121724), - onSurface = Color(0xFFF4F7FB), - onSurfaceVariant = Color(0xFFA2AEC3), - outline = Color(0xFF22304A), - ) - MaterialTheme(colorScheme = colors, content = content) -} - -@Composable -private fun AppRoot(viewModel: MainViewModel) { - val uiState by viewModel.uiState.collectAsStateWithLifecycle() - val context = LocalContext.current - val activity = context as? ComponentActivity - - var currentTab by rememberSaveable { mutableStateOf("studio") } - var disclaimerAccepted by rememberSaveable { mutableStateOf(false) } - - val companionDeviceLauncher = rememberLauncherForActivityResult( - ActivityResultContracts.StartIntentSenderForResult(), - ) { result -> - val data = result.data - Log.d("TagTinkerBle", "Companion picker result: ${result.resultCode}, data=$data") - data?.extras?.keySet()?.forEach { key -> - Log.d("TagTinkerBle", "Extra: $key = ${data.extras?.get(key)}") - } - - val device = extractCompanionBluetoothDevice(data) - if(result.resultCode == Activity.RESULT_OK && device?.address != null) { - viewModel.setStatus("Connecting to ${device.name ?: "device"}") - viewModel.connect(device.address) - } else { - viewModel.setStatus("No device selected (code=${result.resultCode})") - } - } - - val bluetoothPermissionLauncher = rememberLauncherForActivityResult( - ActivityResultContracts.RequestMultiplePermissions(), - ) { result -> - val granted = result.values.all { it } - if (granted) { - if(activity != null) { - launchCompanionPicker(activity, viewModel, companionDeviceLauncher) - } - } - } - - val cameraPermissionLauncher = - rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { } - - val imagePicker = rememberLauncherForActivityResult( - ActivityResultContracts.PickVisualMedia(), - ) { uri -> - uri?.let(viewModel::loadImage) - } - - Surface(color = MaterialTheme.colorScheme.background) { - if (!disclaimerAccepted) { - DisclaimerGate( - onAccept = { disclaimerAccepted = true }, - ) - return@Surface - } - - LaunchedEffect(disclaimerAccepted, currentTab, activity) { - if(disclaimerAccepted && currentTab == "studio" && activity != null && !hasCameraPermission(activity)) { - cameraPermissionLauncher.launch(Manifest.permission.CAMERA) - } - } - - Scaffold( - modifier = Modifier.fillMaxSize(), - topBar = { - TopBar( - status = uiState.status, - connected = uiState.connected, - onConnect = { - if (activity != null && hasBluetoothPermissions(activity)) { - launchCompanionPicker(activity, viewModel, companionDeviceLauncher) - } else { - bluetoothPermissionLauncher.launch(bluetoothPermissions()) - } - }, - onDisconnect = viewModel::disconnect, - ) - }, - bottomBar = { - NavigationBar( - containerColor = Color(0xFF090B10), - tonalElevation = 0.dp, - modifier = Modifier.navigationBarsPadding(), - ) { - NavigationBarItem( - selected = currentTab == "studio", - onClick = { currentTab = "studio" }, - icon = { Icon(Icons.Rounded.Collections, contentDescription = null) }, - label = { Text("Studio") }, - ) - NavigationBarItem( - selected = currentTab == "recents", - onClick = { currentTab = "recents" }, - icon = { Icon(Icons.Rounded.History, contentDescription = null) }, - label = { Text("Recents") }, - ) - } - }, - containerColor = MaterialTheme.colorScheme.background, - ) { padding -> - when (currentTab) { - "recents" -> RecentsScreen( - items = uiState.history, - onUse = { - viewModel.resendHistory(it) - currentTab = "studio" - }, - modifier = Modifier.padding(padding), - ) - - else -> StudioScreen( - uiState = uiState, - cameraReady = activity != null && hasCameraPermission(activity), - onBarcode = viewModel::acceptBarcode, - onPickImage = { - imagePicker.launch(PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly)) - }, - onEditorChange = viewModel::updateEditor, - onSend = viewModel::sendCurrentPayload, - modifier = Modifier.padding(padding), - ) - } - } - - } -} - -@Composable -private fun DisclaimerGate(onAccept: () -> Unit) { - Box( - modifier = Modifier - .fillMaxSize() - .background(Color(0xFF040507)) - .padding(24.dp), - contentAlignment = Alignment.Center, - ) { - Card( - shape = RoundedCornerShape(28.dp), - colors = cardColors(), - modifier = Modifier.fillMaxWidth(), - ) { - Column( - verticalArrangement = Arrangement.spacedBy(14.dp), - modifier = Modifier.padding(22.dp), - ) { - Text("TagTinker", style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.SemiBold) - Text( - "Use this for badges, artwork, lab experiments, IoT reverse engineering, and security curiosity on hardware you own or are allowed to test.", - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - Text( - "Do not use it for unauthorized retail, fraud, or illegal activity.", - color = Color(0xFFFF8B94), - ) - Button( - onClick = onAccept, - modifier = Modifier.fillMaxWidth(), - shape = RoundedCornerShape(18.dp), - ) { - Text("I understand") - } - } - } - } -} - -@Composable -private fun TopBar( - status: String, - connected: Boolean, - onConnect: () -> Unit, - onDisconnect: () -> Unit, -) { - Row( - modifier = Modifier - .fillMaxWidth() - .statusBarsPadding() - .padding(horizontal = 18.dp, vertical = 10.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - Column { - Text("TagTinker", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.SemiBold) - Text( - if (connected) status else "Phone Sync over BLE", - color = MaterialTheme.colorScheme.onSurfaceVariant, - style = MaterialTheme.typography.bodySmall, - ) - } - if (connected) { - OutlinedButton(onClick = onDisconnect, shape = RoundedCornerShape(16.dp)) { - Text("Disconnect") - } - } else { - Button(onClick = onConnect, shape = RoundedCornerShape(16.dp)) { - Icon(Icons.Rounded.Bluetooth, contentDescription = null) - Spacer(Modifier.size(8.dp)) - Text("Connect") - } - } - } -} - -@Composable -private fun StudioScreen( - uiState: MainUiState, - cameraReady: Boolean, - onBarcode: (String) -> Unit, - onPickImage: () -> Unit, - onEditorChange: (EditorState) -> Unit, - onSend: () -> Unit, - modifier: Modifier = Modifier, -) { - val canSend = uiState.connected && uiState.tag != null && uiState.previewBitmap != null - Column( - modifier = modifier - .fillMaxSize() - .verticalScroll(rememberScrollState()) - .padding(horizontal = 16.dp, vertical = 8.dp), - verticalArrangement = Arrangement.spacedBy(14.dp), - ) { - PanelCard { - Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { - Text("Tag", style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold) - CompactBarcodeScanner( - enabled = cameraReady, - onBarcode = onBarcode, - ) - uiState.tag?.let { tag -> - Text( - "${tag.name} ${tag.width}x${tag.height} ${tag.color}", - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } ?: Text( - if(cameraReady) "Point the camera at the tag barcode" else "Allow camera access to scan a tag", - color = MaterialTheme.colorScheme.onSurfaceVariant, - style = MaterialTheme.typography.bodySmall, - ) - } - } - - PanelCard { - Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - Column { - Text("Image", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold) - Text(uiState.payloadSizeLabel, color = MaterialTheme.colorScheme.onSurfaceVariant) - } - OutlinedButton(onClick = onPickImage, shape = RoundedCornerShape(18.dp)) { - Icon(Icons.Rounded.Image, contentDescription = null) - Spacer(Modifier.size(8.dp)) - Text("Pick") - } - } - PreviewPane( - bitmap = uiState.previewBitmap, - tagWidth = uiState.tag?.width, - tagHeight = uiState.tag?.height, - editor = uiState.editor, - onGesture = onEditorChange, - ) - } - } - - if (uiState.tag != null && uiState.sourceBitmap != null) { - EditorPanel(editor = uiState.editor, allowColor = uiState.tag.color != "mono", onChange = onEditorChange) - } - - PanelCard { - Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { - Text("Transfer", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold) - LinearProgressIndicator( - progress = { uiState.progress }, - modifier = Modifier.fillMaxWidth().clip(RoundedCornerShape(99.dp)), - trackColor = MaterialTheme.colorScheme.surfaceVariant, - ) - Text(uiState.status, color = MaterialTheme.colorScheme.onSurfaceVariant) - Button( - onClick = onSend, - enabled = canSend, - modifier = Modifier.fillMaxWidth(), - shape = RoundedCornerShape(18.dp), - ) { - Icon(Icons.Rounded.Send, contentDescription = null) - Spacer(Modifier.size(8.dp)) - Text("Send to Flipper") - } - } - } - } -} - -@Composable -private fun PreviewPane( - bitmap: Bitmap?, - tagWidth: Int?, - tagHeight: Int?, - editor: EditorState, - onGesture: (EditorState) -> Unit, -) { - val aspect = remember(tagWidth, tagHeight) { - if (tagWidth != null && tagHeight != null && tagHeight > 0) tagWidth.toFloat() / tagHeight else 296f / 128f - } - var viewportSize by remember { mutableStateOf(IntSize.Zero) } - var gestureBase by remember(bitmap) { mutableStateOf(editor) } - var gestureScale by remember(bitmap) { mutableFloatStateOf(1f) } - var gestureOffset by remember(bitmap) { mutableStateOf(Offset.Zero) } - val latestGestureBase by rememberUpdatedState(gestureBase) - - LaunchedEffect(bitmap) { - gestureBase = editor - gestureScale = 1f - gestureOffset = Offset.Zero - } - - LaunchedEffect(gestureScale, gestureOffset, viewportSize, bitmap) { - if(bitmap == null || viewportSize.width == 0 || viewportSize.height == 0) return@LaunchedEffect - if(gestureScale == 1f && gestureOffset == Offset.Zero) return@LaunchedEffect - onGesture( - latestGestureBase.copy( - scale = (latestGestureBase.scale * gestureScale).coerceIn(0.6f, 2.6f), - offsetX = (latestGestureBase.offsetX + gestureOffset.x / (viewportSize.width * 0.5f)).coerceIn(-1.25f, 1.25f), - offsetY = (latestGestureBase.offsetY + gestureOffset.y / (viewportSize.height * 0.5f)).coerceIn(-1.25f, 1.25f), - ), - ) - } - - Box( - modifier = Modifier - .fillMaxWidth() - .aspectRatio(aspect) - .onSizeChanged { viewportSize = it } - .clip(RoundedCornerShape(24.dp)) - .background(Color.White) - .border(1.dp, Color(0xFF1C2434), RoundedCornerShape(24.dp)) - .pointerInput(bitmap) { - detectTransformGestures { _, pan, zoom, _ -> - if(bitmap == null) return@detectTransformGestures - gestureScale = (gestureScale * zoom).coerceIn(0.6f / gestureBase.scale, 2.6f / gestureBase.scale) - gestureOffset = Offset( - x = gestureOffset.x + pan.x, - y = gestureOffset.y + pan.y, - ) - } - }, - contentAlignment = Alignment.Center, - ) { - if (bitmap == null) { - Canvas(modifier = Modifier.fillMaxSize()) { - drawRect(color = Color(0xFFF7F9FC)) - drawCircle(color = Color(0xFFCAD2E2), radius = size.minDimension * 0.12f, style = Stroke(width = 4f)) - } - Text("Waiting for image", color = Color(0xFF667085)) - } else { - Image( - bitmap = bitmap.asImageBitmap(), - contentDescription = null, - contentScale = when(editor.fitMode) { - FitMode.Fill -> ContentScale.Crop - FitMode.Stretch -> ContentScale.FillBounds - FitMode.Fit -> ContentScale.Fit - }, - modifier = Modifier - .fillMaxSize() - .graphicsLayer { - scaleX = gestureScale - scaleY = gestureScale - translationX = gestureOffset.x - translationY = gestureOffset.y - }, - ) - Text( - "Pinch and drag", - color = Color(0xFF667085), - style = MaterialTheme.typography.labelSmall, - modifier = Modifier - .align(Alignment.BottomCenter) - .padding(bottom = 10.dp) - .background(Color(0xE8F8FAFF), RoundedCornerShape(99.dp)) - .padding(horizontal = 10.dp, vertical = 4.dp), - ) - } - } -} - -@OptIn(ExperimentalLayoutApi::class) -@Composable -private fun EditorPanel(editor: EditorState, allowColor: Boolean, onChange: (EditorState) -> Unit) { - PanelCard { - Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { - Text("Adjust", style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold) - FlowRow(horizontalArrangement = Arrangement.spacedBy(6.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) { - FitMode.entries.forEach { mode -> - FilterChip( - selected = editor.fitMode == mode, - onClick = { onChange(editor.copy(fitMode = mode)) }, - label = { Text(mode.name, style = MaterialTheme.typography.labelMedium) }, - modifier = Modifier.height(32.dp), - ) - } - } - FlowRow(horizontalArrangement = Arrangement.spacedBy(6.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) { - DitherMode.entries.forEach { mode -> - FilterChip( - selected = editor.ditherMode == mode, - onClick = { onChange(editor.copy(ditherMode = mode)) }, - label = { Text(mode.name, style = MaterialTheme.typography.labelMedium) }, - modifier = Modifier.height(32.dp), - ) - } - } - LabeledSlider("Brightness", editor.brightness.toFloat(), -80f..80f) { onChange(editor.copy(brightness = it.toInt())) } - LabeledSlider("Contrast", editor.contrast.toFloat(), -60f..90f) { onChange(editor.copy(contrast = it.toInt())) } - LabeledSlider("Detail", editor.detail.toFloat(), 10f..90f) { onChange(editor.copy(detail = it.toInt())) } - Column(modifier = Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(6.dp)) { - Text("Page", style = MaterialTheme.typography.labelLarge) - FlowRow( - horizontalArrangement = Arrangement.spacedBy(6.dp), - verticalArrangement = Arrangement.spacedBy(6.dp), - modifier = Modifier.fillMaxWidth(), - ) { - (1..8).forEach { page -> - FilterChip( - selected = editor.page == page, - onClick = { onChange(editor.copy(page = page)) }, - label = { Text(page.toString(), style = MaterialTheme.typography.labelMedium) }, - modifier = Modifier.height(32.dp), - ) - } - } - } - if (allowColor) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - Text("Use color", style = MaterialTheme.typography.labelLarge) - Switch( - checked = editor.useColor, - onCheckedChange = { onChange(editor.copy(useColor = it)) }, - ) - } - } - } - } -} - -@Composable -private fun LabeledSlider(label: String, value: Float, range: ClosedFloatingPointRange, onChange: (Float) -> Unit) { - Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { - Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { - Text(label, style = MaterialTheme.typography.labelLarge) - Text( - when { - range.endInclusive - range.start <= 3f -> String.format("%.2f", value) - else -> value.toInt().toString() - }, - color = MaterialTheme.colorScheme.onSurfaceVariant, - style = MaterialTheme.typography.labelMedium, - ) - } - Slider( - value = value, - onValueChange = onChange, - valueRange = range, - modifier = Modifier.height(26.dp), - ) - } -} - -@Composable -private fun RecentsScreen(items: List, onUse: (PayloadHistoryItem) -> Unit, modifier: Modifier = Modifier) { - if (items.isEmpty()) { - Box(modifier = modifier.fillMaxSize(), contentAlignment = Alignment.Center) { - Text("No recent payloads yet", color = MaterialTheme.colorScheme.onSurfaceVariant) - } - return - } - LazyColumn( - modifier = modifier.fillMaxSize(), - verticalArrangement = Arrangement.spacedBy(10.dp), - contentPadding = androidx.compose.foundation.layout.PaddingValues(16.dp), - ) { - items(items, key = { it.id }) { item -> - PanelCard { - Row( - modifier = Modifier - .fillMaxWidth() - .clickable { onUse(item) }, - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(4.dp)) { - Text(item.name, fontWeight = FontWeight.SemiBold) - Text(item.barcode, color = MaterialTheme.colorScheme.onSurfaceVariant) - Text( - "${item.width}x${item.height} page ${item.page} ${DateFormat.getDateTimeInstance().format(Date(item.timestamp))}", - color = MaterialTheme.colorScheme.onSurfaceVariant, - style = MaterialTheme.typography.bodySmall, - ) - } - Button(onClick = { onUse(item) }, shape = RoundedCornerShape(16.dp)) { - Icon(Icons.Rounded.Sync, contentDescription = null) - Spacer(Modifier.size(6.dp)) - Text("Use") - } - } - } - } - } -} - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -private fun DeviceSheet( - devices: List, - scanning: Boolean, - onDismiss: () -> Unit, - onSelect: (BlePeripheral) -> Unit, -) { - ModalBottomSheet( - onDismissRequest = onDismiss, - containerColor = Color(0xFF0C1017), - dragHandle = null, - ) { - Column( - modifier = Modifier - .fillMaxWidth() - .navigationBarsPadding() - .padding(18.dp), - verticalArrangement = Arrangement.spacedBy(12.dp), - ) { - Text("Choose Flipper", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.SemiBold) - Text( - if (scanning) "Select the device running Phone Sync" else "Scan stopped", - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - if (devices.isEmpty()) { - PanelCard { - Text("Scanning...", color = MaterialTheme.colorScheme.onSurfaceVariant) - } - } else { - devices.forEach { device -> - PanelCard { - Row( - modifier = Modifier - .fillMaxWidth() - .clickable { onSelect(device) }, - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { - Text(device.name.ifBlank { "Unnamed BLE device" }, fontWeight = FontWeight.SemiBold) - Text(device.address, color = MaterialTheme.colorScheme.onSurfaceVariant) - } - Text("${device.rssi} dBm", color = MaterialTheme.colorScheme.primary) - } - } - } - } - } - } -} - -@OptIn(ExperimentalGetImage::class) -@Composable -private fun CompactBarcodeScanner(enabled: Boolean, onBarcode: (String) -> Unit) { - if(!enabled) { - Box( - modifier = Modifier - .fillMaxWidth() - .height(112.dp) - .clip(RoundedCornerShape(20.dp)) - .background(Color(0xFF111722)), - contentAlignment = Alignment.Center, - ) { - Text("Camera unavailable", color = MaterialTheme.colorScheme.onSurfaceVariant) - } - return - } - - val context = LocalContext.current - val lifecycleOwner = LocalLifecycleOwner.current - val cameraProviderFuture = remember { ProcessCameraProvider.getInstance(context) } - val executor = remember { Executors.newSingleThreadExecutor() } - val scanner = remember { - BarcodeScanning.getClient( - BarcodeScannerOptions.Builder() - .setBarcodeFormats( - Barcode.FORMAT_EAN_13, - Barcode.FORMAT_EAN_8, - Barcode.FORMAT_UPC_A, - Barcode.FORMAT_UPC_E, - Barcode.FORMAT_CODE_128, - Barcode.FORMAT_CODE_39, - Barcode.FORMAT_CODE_93, - Barcode.FORMAT_ITF, - Barcode.FORMAT_CODABAR, - Barcode.FORMAT_QR_CODE, - ) - .build(), - ) - } - val lastBarcode = remember { mutableStateOf("") } - val stableHits = remember { mutableIntStateOf(0) } - - DisposableEffect(Unit) { - onDispose { - executor.shutdown() - scanner.close() - } - } - - Box( - modifier = Modifier - .fillMaxWidth() - .height(112.dp) - .clip(RoundedCornerShape(20.dp)) - .background(Color.Black), - ) { - AndroidView( - factory = { ctx -> - PreviewView(ctx).apply { - scaleType = PreviewView.ScaleType.FILL_CENTER - implementationMode = PreviewView.ImplementationMode.COMPATIBLE - } - }, - modifier = Modifier.fillMaxSize(), - update = { previewView -> - val provider = cameraProviderFuture.get() - val preview = androidx.camera.core.Preview.Builder().build().also { - it.surfaceProvider = previewView.surfaceProvider - } - val analysis = ImageAnalysis.Builder() - .setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST) - .setTargetResolution(Size(1920, 1080)) - .build() - analysis.setAnalyzer(executor) { imageProxy -> - processFrame(imageProxy, scanner, null) { barcode -> - if(barcode != lastBarcode.value) { - lastBarcode.value = barcode - stableHits.intValue = 1 - } else { - stableHits.intValue += 1 - } - if(stableHits.intValue >= 2) { - onBarcode(barcode) - } - } - } - provider.unbindAll() - val camera = provider.bindToLifecycle( - lifecycleOwner, - CameraSelector.DEFAULT_BACK_CAMERA, - preview, - analysis, - ) - runCatching { camera.cameraControl.setZoomRatio(1.8f) } - }, - ) - Box( - modifier = Modifier - .fillMaxSize() - .padding(12.dp) - .border(2.dp, Color(0xAA8B78FF), RoundedCornerShape(18.dp)), - ) - } -} - -@OptIn(ExperimentalGetImage::class) -@Composable -private fun BarcodeScannerScreen(onClose: () -> Unit, onBarcode: (String) -> Unit) { - val context = LocalContext.current - val lifecycleOwner = LocalLifecycleOwner.current - val cameraProviderFuture = remember { ProcessCameraProvider.getInstance(context) } - val executor = remember { Executors.newSingleThreadExecutor() } - val consumed = remember { AtomicBoolean(false) } - val scanner = remember { - BarcodeScanning.getClient( - BarcodeScannerOptions.Builder() - .setBarcodeFormats( - Barcode.FORMAT_EAN_13, - Barcode.FORMAT_EAN_8, - Barcode.FORMAT_UPC_A, - Barcode.FORMAT_UPC_E, - Barcode.FORMAT_CODE_128, - Barcode.FORMAT_CODE_39, - Barcode.FORMAT_CODE_93, - Barcode.FORMAT_ITF, - Barcode.FORMAT_CODABAR, - Barcode.FORMAT_QR_CODE, - ) - .build(), - ) - } - - DisposableEffect(Unit) { - onDispose { - executor.shutdown() - scanner.close() - } - } - - Box(modifier = Modifier.fillMaxSize().background(Color.Black)) { - AndroidView( - factory = { ctx -> - PreviewView(ctx).apply { - scaleType = PreviewView.ScaleType.FILL_CENTER - implementationMode = PreviewView.ImplementationMode.COMPATIBLE - } - }, - modifier = Modifier.fillMaxSize(), - update = { previewView -> - val provider = cameraProviderFuture.get() - val preview = androidx.camera.core.Preview.Builder().build().also { - it.surfaceProvider = previewView.surfaceProvider - } - val analysis = ImageAnalysis.Builder() - .setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST) - .setTargetResolution(Size(1920, 1080)) - .build() - analysis.setAnalyzer(executor) { imageProxy -> - processFrame(imageProxy, scanner, consumed, onBarcode) - } - provider.unbindAll() - val camera = provider.bindToLifecycle( - lifecycleOwner, - CameraSelector.DEFAULT_BACK_CAMERA, - preview, - analysis, - ) - runCatching { camera.cameraControl.setZoomRatio(1.8f) } - }, - ) - - Box( - modifier = Modifier - .fillMaxSize() - .padding(28.dp), - ) { - Column( - modifier = Modifier.align(Alignment.TopCenter).padding(top = 40.dp), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - Text("Point at the tag barcode", color = Color.White, textAlign = TextAlign.Center) - Spacer(Modifier.height(14.dp)) - Box( - modifier = Modifier - .fillMaxWidth() - .aspectRatio(1.8f) - .border(2.dp, Color(0xFF8B78FF), RoundedCornerShape(24.dp)), - ) - } - IconButton( - onClick = onClose, - modifier = Modifier - .align(Alignment.TopStart) - .statusBarsPadding() - .background(Color(0x66000000), CircleShape), - ) { - Text("X", color = Color.White) - } - } - } -} - -private fun processFrame( - imageProxy: ImageProxy, - scanner: com.google.mlkit.vision.barcode.BarcodeScanner, - consumed: AtomicBoolean?, - onBarcode: (String) -> Unit, -) { - val mediaImage = imageProxy.image ?: run { - imageProxy.close() - return - } - if (consumed?.get() == true) { - imageProxy.close() - return - } - val image = InputImage.fromMediaImage(mediaImage, imageProxy.imageInfo.rotationDegrees) - scanner.process(image) - .addOnSuccessListener { results -> - val value = results.firstOrNull { !it.rawValue.isNullOrBlank() }?.rawValue - if (!value.isNullOrBlank()) { - if(consumed == null || consumed.compareAndSet(false, true)) { - onBarcode(value) - } - } - } - .addOnCompleteListener { imageProxy.close() } -} - -@Composable -private fun PanelCard(content: @Composable ColumnScope.() -> Unit) { - Card( - colors = cardColors(), - shape = RoundedCornerShape(24.dp), - modifier = Modifier.fillMaxWidth(), - ) { - Column( - modifier = Modifier.padding(16.dp), - verticalArrangement = Arrangement.spacedBy(8.dp), - content = content, - ) - } -} - -@Composable -private fun cardColors() = CardDefaults.cardColors(containerColor = Color(0xFF0B0F16)) - -private fun bluetoothPermissions(): Array { - return buildList { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { - add(Manifest.permission.BLUETOOTH_SCAN) - add(Manifest.permission.BLUETOOTH_CONNECT) - } else { - add(Manifest.permission.ACCESS_FINE_LOCATION) - } - }.toTypedArray() -} - -private fun hasBluetoothPermissions(activity: ComponentActivity): Boolean { - return bluetoothPermissions().all { - ContextCompat.checkSelfPermission(activity, it) == PackageManager.PERMISSION_GRANTED - } -} - -private fun hasCameraPermission(activity: ComponentActivity): Boolean { - return ContextCompat.checkSelfPermission(activity, Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED -} - -private fun launchCompanionPicker( - activity: ComponentActivity, - viewModel: MainViewModel, - launcher: androidx.activity.result.ActivityResultLauncher, -) { - val manager = activity.getSystemService(CompanionDeviceManager::class.java) - if(manager == null) { - viewModel.setStatus("Companion device picker unavailable") - return - } - val filter = BluetoothLeDeviceFilter.Builder() - .setNamePattern(Pattern.compile(".*", Pattern.CASE_INSENSITIVE)) - .build() - val request = AssociationRequest.Builder() - .addDeviceFilter(filter) - .setSingleDevice(false) - .build() - viewModel.setStatus("Searching nearby BLE devices") - manager.associate( - request, - activity.mainExecutor, - object : CompanionDeviceManager.Callback() { - override fun onAssociationPending(intentSender: android.content.IntentSender) { - launcher.launch(IntentSenderRequest.Builder(intentSender).build()) - } - - override fun onDeviceFound(intentSender: android.content.IntentSender) { - launcher.launch(IntentSenderRequest.Builder(intentSender).build()) - } - - override fun onFailure(error: CharSequence?) { - viewModel.setStatus(error?.toString() ?: "No nearby device found") - } - }, - ) -} - -@Suppress("DEPRECATION") -private fun extractCompanionBluetoothDevice(intent: android.content.Intent?): BluetoothDevice? { - if(intent == null) return null - - // Some Android versions/devices wrap the device in a ScanResult - val scanResult = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - intent.getParcelableExtra(CompanionDeviceManager.EXTRA_DEVICE, android.bluetooth.le.ScanResult::class.java) - } else { - intent.getParcelableExtra(CompanionDeviceManager.EXTRA_DEVICE) as? android.bluetooth.le.ScanResult - } - if (scanResult != null) return scanResult.device - - // Others return the BluetoothDevice directly - return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - intent.getParcelableExtra(CompanionDeviceManager.EXTRA_DEVICE, BluetoothDevice::class.java) - } else { - intent.getParcelableExtra(CompanionDeviceManager.EXTRA_DEVICE) - } -} diff --git a/android-companion/app/src/main/java/xyz/i12bp8/tagtinker/android/MainViewModel.kt b/android-companion/app/src/main/java/xyz/i12bp8/tagtinker/android/MainViewModel.kt deleted file mode 100644 index 4023574..0000000 --- a/android-companion/app/src/main/java/xyz/i12bp8/tagtinker/android/MainViewModel.kt +++ /dev/null @@ -1,265 +0,0 @@ -package xyz.i12bp8.tagtinker.android - -import android.app.Application -import android.graphics.Bitmap -import android.graphics.BitmapFactory -import android.net.Uri -import androidx.lifecycle.AndroidViewModel -import androidx.lifecycle.viewModelScope -import java.io.File -import java.util.UUID -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job -import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import xyz.i12bp8.tagtinker.android.ble.FlipperBleClient -import xyz.i12bp8.tagtinker.android.data.HistoryStore -import xyz.i12bp8.tagtinker.android.data.isValidBarcode -import xyz.i12bp8.tagtinker.android.data.normalizeBarcode -import xyz.i12bp8.tagtinker.android.data.tagFromBarcode -import xyz.i12bp8.tagtinker.android.model.BlePeripheral -import xyz.i12bp8.tagtinker.android.model.EditorState -import xyz.i12bp8.tagtinker.android.model.PayloadHistoryItem -import xyz.i12bp8.tagtinker.android.model.TagRecord -import xyz.i12bp8.tagtinker.android.model.UploadJob -import xyz.i12bp8.tagtinker.android.model.UploadProgress -import xyz.i12bp8.tagtinker.android.render.ImagePipeline - -data class MainUiState( - val status: String = "Disconnected", - val connected: Boolean = false, - val scanning: Boolean = false, - val devices: List = emptyList(), - val tag: TagRecord? = null, - val editor: EditorState = EditorState(), - val sourceBitmap: Bitmap? = null, - val previewBitmap: Bitmap? = null, - val payloadSizeLabel: String = "No image", - val progress: Float = 0f, - val logs: List = emptyList(), - val history: List = emptyList(), - val manualBarcode: String = "", -) - -class MainViewModel(application: Application) : AndroidViewModel(application) { - private val bleClient = FlipperBleClient(application) - private val historyStore = HistoryStore(application) - private val _uiState = MutableStateFlow(MainUiState(history = historyStore.load())) - val uiState: StateFlow = _uiState.asStateFlow() - private var renderJob: Job? = null - - init { - viewModelScope.launch { - bleClient.scanResults.collect { results -> - _uiState.value = _uiState.value.copy(devices = results) - } - } - viewModelScope.launch { - bleClient.scanStatus.collect { status -> - if (!status.isNullOrBlank() && !_uiState.value.connected) { - _uiState.value = _uiState.value.copy(status = status) - } - } - } - } - - fun startScan() { - bleClient.startScan() - _uiState.value = _uiState.value.copy(scanning = true, status = "Scanning nearby Flippers") - log("Scanning") - } - - fun stopScan() { - bleClient.stopScan() - _uiState.value = _uiState.value.copy(scanning = false) - } - - fun setStatus(status: String) { - _uiState.value = _uiState.value.copy(status = status) - log(status) - } - - fun connect(address: String) { - viewModelScope.launch { - runCatching { - bleClient.connect(address) - }.onSuccess { - _uiState.value = _uiState.value.copy( - connected = true, - scanning = false, - status = "BLE ready", - ) - log("BLE ready") - }.onFailure { - _uiState.value = _uiState.value.copy(status = it.message ?: "BLE failed") - log(it.message ?: "BLE failed") - } - } - } - - fun disconnect() { - viewModelScope.launch { - bleClient.disconnect() - _uiState.value = MainUiState(history = historyStore.load()) - } - } - - fun onManualBarcodeChanged(value: String) { - _uiState.value = _uiState.value.copy(manualBarcode = value) - } - - fun acceptBarcode(raw: String) { - val barcode = normalizeBarcode(raw) - if (!isValidBarcode(barcode)) { - log("Invalid barcode") - return - } - _uiState.value = _uiState.value.copy( - tag = tagFromBarcode(barcode), - manualBarcode = barcode, - ) - renderPreview() - log("Loaded tag $barcode") - } - - fun loadImage(uri: Uri) { - viewModelScope.launch { - val bitmap = withContext(Dispatchers.IO) { - getApplication().contentResolver.openInputStream(uri)?.use { input -> - BitmapFactory.decodeStream(input) - } - } ?: return@launch - _uiState.value = _uiState.value.copy(sourceBitmap = bitmap) - renderPreview() - log("Loaded image") - } - } - - fun updateEditor(transform: EditorState) { - _uiState.value = _uiState.value.copy(editor = transform) - scheduleRenderPreview() - } - - fun updateEditor(mutator: (EditorState) -> EditorState) { - _uiState.value = _uiState.value.copy(editor = mutator(_uiState.value.editor)) - scheduleRenderPreview() - } - - private fun scheduleRenderPreview() { - renderJob?.cancel() - renderJob = viewModelScope.launch { - delay(72) - renderPreview() - } - } - - private fun renderPreview() { - val tag = _uiState.value.tag ?: return - val source = _uiState.value.sourceBitmap ?: run { - _uiState.value = _uiState.value.copy(previewBitmap = null, payloadSizeLabel = "No image") - return - } - viewModelScope.launch(Dispatchers.Default) { - val result = ImagePipeline.render( - source = source, - width = tag.width, - height = tag.height, - accentColor = tag.color, - editor = _uiState.value.editor, - ) - withContext(Dispatchers.Main) { - val cacheFile = File(getApplication().cacheDir, "latest_payload.bmp") - cacheFile.writeBytes(result.bmpBytes) - _uiState.value = _uiState.value.copy( - previewBitmap = result.bitmap, - payloadSizeLabel = "${result.bmpBytes.size} B", - ) - } - } - } - - fun sendCurrentPayload() { - val state = _uiState.value - val tag = state.tag ?: return - val bmpFile = File(getApplication().cacheDir, "latest_payload.bmp") - if (!bmpFile.exists()) { - log("No payload rendered") - return - } - viewModelScope.launch { - _uiState.value = _uiState.value.copy( - progress = 0f, - status = "Uploading to Flipper", - ) - runCatching { - bleClient.upload( - UploadJob( - jobId = UUID.randomUUID().toString().replace("-", "").take(6).uppercase(), - barcode = tag.barcode, - width = tag.width, - height = tag.height, - page = (state.editor.page - 1).coerceAtLeast(0), - bmpBytes = bmpFile.readBytes(), - ), - ) { progress -> - applyProgress(progress) - } - }.onSuccess { - val stamped = File( - getApplication().filesDir, - "payload-${System.currentTimeMillis()}.bmp", - ) - bmpFile.copyTo(stamped, overwrite = true) - val updated = listOf( - PayloadHistoryItem( - id = UUID.randomUUID().toString(), - barcode = tag.barcode, - name = tag.name, - width = tag.width, - height = tag.height, - color = tag.color, - page = state.editor.page, - bmpPath = stamped.absolutePath, - timestamp = System.currentTimeMillis(), - ), - ) + _uiState.value.history - historyStore.save(updated) - _uiState.value = _uiState.value.copy( - history = updated.take(24), - progress = 1f, - status = "Saved on Flipper", - ) - log("Upload complete") - }.onFailure { - _uiState.value = _uiState.value.copy(status = it.message ?: "Upload failed") - log(it.message ?: "Upload failed") - } - } - } - - fun resendHistory(item: PayloadHistoryItem) { - acceptBarcode(item.barcode) - _uiState.value = _uiState.value.copy( - editor = _uiState.value.editor.copy(page = item.page), - sourceBitmap = BitmapFactory.decodeFile(item.bmpPath), - ) - renderPreview() - } - - private fun applyProgress(progress: UploadProgress) { - _uiState.value = _uiState.value.copy( - progress = if (progress.bytesTotal == 0) 0f else progress.bytesDone.toFloat() / progress.bytesTotal, - status = progress.label, - ) - } - - private fun log(message: String) { - _uiState.value = _uiState.value.copy( - logs = (_uiState.value.logs + "${System.currentTimeMillis()}: $message").takeLast(24), - ) - } -} diff --git a/android-companion/app/src/main/java/xyz/i12bp8/tagtinker/android/ble/FlipperBleClient.kt b/android-companion/app/src/main/java/xyz/i12bp8/tagtinker/android/ble/FlipperBleClient.kt deleted file mode 100644 index 26e3472..0000000 --- a/android-companion/app/src/main/java/xyz/i12bp8/tagtinker/android/ble/FlipperBleClient.kt +++ /dev/null @@ -1,284 +0,0 @@ -package xyz.i12bp8.tagtinker.android.ble - -import android.Manifest -import android.annotation.SuppressLint -import android.content.Context -import android.content.pm.PackageManager -import android.bluetooth.BluetoothAdapter -import android.bluetooth.BluetoothManager -import android.util.Log -import androidx.core.content.ContextCompat -import java.util.UUID -import kotlinx.coroutines.CompletableDeferred -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job -import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.channels.BufferOverflow -import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.filter -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.launch -import kotlinx.coroutines.withTimeout -import kotlinx.coroutines.withTimeoutOrNull -import no.nordicsemi.android.kotlin.ble.client.main.callback.ClientBleGatt -import no.nordicsemi.android.kotlin.ble.client.main.service.ClientBleGattCharacteristic -import no.nordicsemi.android.kotlin.ble.core.data.BleWriteType -import no.nordicsemi.android.kotlin.ble.core.data.BleGattConnectOptions -import no.nordicsemi.android.kotlin.ble.core.data.util.DataByteArray -import no.nordicsemi.android.kotlin.ble.core.scanner.BleScanMode -import no.nordicsemi.android.kotlin.ble.core.scanner.BleScannerSettings -import no.nordicsemi.android.kotlin.ble.scanner.BleScanner -import no.nordicsemi.android.kotlin.ble.scanner.aggregator.BleScanResultAggregator -import xyz.i12bp8.tagtinker.android.model.BlePeripheral -import xyz.i12bp8.tagtinker.android.model.UploadJob -import xyz.i12bp8.tagtinker.android.model.UploadProgress - -// Official Flipper Serial UUIDs -private val serialServiceUuid = UUID.fromString("8fe5b3d5-2e7f-4a98-2a48-7acc60fe0000") -private val serialWriteUuid = UUID.fromString("19ed82ae-ed21-4c9d-4145-228e62fe0000") // Phone -> Flipper -private val serialNotifyUuid = UUID.fromString("19ed82ae-ed21-4c9d-4145-228e61fe0000") // Flipper -> Phone - -private const val logTag = "TagTinkerBle" -private const val connectTimeoutMs = 30000L -private const val mtuSize = 247 - -// How many bytes per chunk we send to the Flipper. -// 128 bytes → ~172 chars base64 → well under BLE MTU with header. -private const val CHUNK_BYTES = 128 - -class FlipperBleClient(private val context: Context) { - private val appScope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate) - private val adapter: BluetoothAdapter? = - context.getSystemService(BluetoothManager::class.java)?.adapter - - private val _scanResults = MutableStateFlow>(emptyList()) - val scanResults: StateFlow> = _scanResults - private val _scanStatus = MutableStateFlow(null) - val scanStatus: StateFlow = _scanStatus - - private var notifyJob: Job? = null - private var client: ClientBleGatt? = null - - private var writeCharacteristic: ClientBleGattCharacteristic? = null - private var notifyCharacteristic: ClientBleGattCharacteristic? = null - - private val ackWaiters = linkedMapOf>() - private val rxBuffer = StringBuilder() - - private var helloReceived = false - private var pongReceived = false - - fun isAvailable(): Boolean { - return ContextCompat.checkSelfPermission(context, Manifest.permission.BLUETOOTH_CONNECT) == - PackageManager.PERMISSION_GRANTED - } - - fun startScan() { _scanStatus.value = "Use System Picker" } - fun stopScan() {} - - @SuppressLint("MissingPermission") - suspend fun connect(address: String) { - Log.d(logTag, "Connecting to $address...") - disconnect() - - val bluetoothDevice = adapter?.getRemoteDevice(address) ?: error("Device not found") - val serverDevice = no.nordicsemi.android.kotlin.ble.core.RealServerDevice(bluetoothDevice) - - val gatt = withTimeout(connectTimeoutMs) { - ClientBleGatt.connect(context, serverDevice, appScope, BleGattConnectOptions(autoConnect = false)) - } - - if (!gatt.isConnected) error("Connection failed") - this.client = gatt - - Log.d(logTag, "GATT connected, waiting for bond...") - try { - withTimeout(15000) { gatt.waitForBonding() } - } catch (e: Exception) { - Log.w(logTag, "Bonding stalled, continuing anyway") - } - - gatt.requestMtu(mtuSize) - delay(500) - - Log.d(logTag, "Discovering services...") - gatt.discoverServices() - - val services = withTimeout(20000) { - var found: no.nordicsemi.android.kotlin.ble.client.main.service.ClientBleGattServices? = null - repeat(3) { attempt -> - if (attempt > 0) { - Log.d(logTag, "Retrying service discovery (attempt ${attempt + 1})...") - gatt.discoverServices() - } - found = withTimeoutOrNull(5000) { - gatt.services.filter { it != null }.first() - } - if (found != null) return@withTimeout found - delay(1000) - } - found - } ?: error("BLE services unavailable") - - val serialService = services.findService(serialServiceUuid) ?: error("Serial service missing") - - notifyCharacteristic = serialService.findCharacteristic(serialNotifyUuid) - writeCharacteristic = serialService.findCharacteristic(serialWriteUuid) - - helloReceived = false - pongReceived = false - - val notifyProps = notifyCharacteristic?.properties - Log.d(logTag, "Notify char props: $notifyProps") - - notifyJob = appScope.launch { - if (notifyProps?.contains(no.nordicsemi.android.kotlin.ble.core.data.BleGattProperty.PROPERTY_NOTIFY) == true || - notifyProps?.contains(no.nordicsemi.android.kotlin.ble.core.data.BleGattProperty.PROPERTY_INDICATE) == true) { - notifyCharacteristic?.getNotifications(bufferOverflow = BufferOverflow.SUSPEND)?.collect { - handleNotify(it.value) - } - } else { - Log.e(logTag, "Notify characteristic has no NOTIFY or INDICATE property!") - } - } - - Log.d(logTag, "Subscribed to notifications, waiting for handshake...") - sendHandshake() - } - - private suspend fun sendHandshake() { - // Wait briefly to see if Flipper auto-sends TT_HELLO - delay(3000) - if (helloReceived) return - - Log.d(logTag, "Flipper quiet — sending TT_PING poke...") - repeat(3) { attempt -> - runCatching { - sendLine("TT_PING", ack = "TT_PONG") - }.onSuccess { - Log.d(logTag, "Handshake OK via PING") - return - }.onFailure { - Log.w(logTag, "Handshake attempt ${attempt + 1} failed: ${it.message}") - delay(1200) - } - } - - if (!helloReceived && !pongReceived) { - throw IllegalStateException("Handshake failed — Flipper not responding") - } - } - - suspend fun disconnect() { - notifyJob?.cancel() - notifyJob = null - runCatching { client?.disconnect() } - runCatching { client?.close() } - client = null - ackWaiters.values.forEach { it.cancel() } - ackWaiters.clear() - rxBuffer.clear() - } - - private fun handleNotify(bytes: ByteArray) { - if (bytes.isEmpty()) return - Log.d(logTag, "RX Packet: ${bytes.joinToString("") { "%02X".format(it) }}") - - String(bytes, Charsets.UTF_8).forEach { char -> - if (char == '\n' || char == '\r') { - val line = rxBuffer.toString().trim() - rxBuffer.clear() - if (line.isNotBlank()) { - Log.d(logTag, "RX Line: $line") - if (line == "TT_HELLO") helloReceived = true - if (line == "TT_PONG") pongReceived = true - ackWaiters.remove(line)?.complete(line) - } - } else if (char.code in 32..126) { - rxBuffer.append(char) - } - } - } - - /** - * Sends a line to the Flipper and optionally waits for a specific ACK response. - * - * Flow control is entirely ACK-based: the phone sends one chunk, waits for - * TT_ACK|N from the Flipper, then sends the next. There is no credit/window - * mechanism — that approach was unreliable for large images. - * - * We retry up to [maxAttempts] times with [ackTimeoutMs] timeout each. - */ - suspend fun sendLine( - line: String, - ack: String? = null, - ackTimeoutMs: Long = 8000L, - maxAttempts: Int = 3, - ) { - val char = writeCharacteristic ?: error("Not connected") - val payload = "$line\n".encodeToByteArray() - - repeat(maxAttempts) { attempt -> - val deferred = ack?.let { CompletableDeferred() } - if (ack != null) ackWaiters[ack] = deferred!! - - Log.d(logTag, "TX (attempt ${attempt + 1}/$maxAttempts): $line") - char.write(DataByteArray(payload), BleWriteType.NO_RESPONSE) - - if (ack == null) return - - try { - withTimeout(ackTimeoutMs) { deferred!!.await() } - return // ACK received — success - } catch (e: Exception) { - Log.w(logTag, "Timeout waiting for '$ack' (attempt ${attempt + 1})") - ackWaiters.remove(ack) - if (attempt < maxAttempts - 1) delay(200) // brief pause before retry - } - } - - error("Timed out after $maxAttempts attempts sending: $line") - } - - /** - * Uploads a BMP image to the Flipper via the TT_BEGIN / TT_DATA / TT_END protocol. - * - * Each chunk is sent and confirmed via ACK before the next chunk is sent. - * Chunk size is [CHUNK_BYTES] bytes (128), encoded as URL-safe base64. - */ - suspend fun upload(job: UploadJob, onProgress: (UploadProgress) -> Unit) { - onProgress(UploadProgress(0, job.bmpBytes.size, "Preparing...")) - - // Verify connection is alive - sendLine("TT_PING", ack = "TT_PONG") - - // Begin upload — Flipper opens file and responds TT_ACK|BEGIN - val beginCmd = "TT_BEGIN|${job.jobId}|${job.barcode}|${job.width}|${job.height}|${job.page}|${job.bmpBytes.size}" - sendLine(beginCmd, ack = "TT_ACK|BEGIN", ackTimeoutMs = 10000L) - - // Stream chunks — each chunk waits for its individual ACK - var offset = 0 - var sequence = 1 - while (offset < job.bmpBytes.size) { - val end = minOf(job.bmpBytes.size, offset + CHUNK_BYTES) - val chunk = job.bmpBytes.copyOfRange(offset, end) - val b64 = android.util.Base64.encodeToString( - chunk, - android.util.Base64.NO_WRAP or android.util.Base64.URL_SAFE - ) - - onProgress(UploadProgress(offset, job.bmpBytes.size, "Chunk $sequence / ${(job.bmpBytes.size + CHUNK_BYTES - 1) / CHUNK_BYTES}")) - sendLine("TT_DATA|$sequence|$b64", ack = "TT_ACK|$sequence", ackTimeoutMs = 10000L) - - offset = end - sequence++ - } - - // Finalize — Flipper renames file and marks ready - sendLine("TT_END|${job.jobId}", ack = "TT_ACK|END", ackTimeoutMs = 15000L) - onProgress(UploadProgress(job.bmpBytes.size, job.bmpBytes.size, "Sync complete!")) - } -} diff --git a/android-companion/app/src/main/java/xyz/i12bp8/tagtinker/android/data/HistoryStore.kt b/android-companion/app/src/main/java/xyz/i12bp8/tagtinker/android/data/HistoryStore.kt deleted file mode 100644 index 8f21d52..0000000 --- a/android-companion/app/src/main/java/xyz/i12bp8/tagtinker/android/data/HistoryStore.kt +++ /dev/null @@ -1,52 +0,0 @@ -package xyz.i12bp8.tagtinker.android.data - -import android.content.Context -import org.json.JSONArray -import org.json.JSONObject -import xyz.i12bp8.tagtinker.android.model.PayloadHistoryItem - -class HistoryStore(context: Context) { - private val prefs = context.getSharedPreferences("tagtinker_history", Context.MODE_PRIVATE) - - fun load(): List { - val raw = prefs.getString("items", "[]") ?: "[]" - val json = JSONArray(raw) - return buildList { - for (index in 0 until json.length()) { - val item = json.getJSONObject(index) - add( - PayloadHistoryItem( - id = item.getString("id"), - barcode = item.getString("barcode"), - name = item.getString("name"), - width = item.getInt("width"), - height = item.getInt("height"), - color = item.getString("color"), - page = item.getInt("page"), - bmpPath = item.getString("bmpPath"), - timestamp = item.getLong("timestamp"), - ), - ) - } - } - } - - fun save(items: List) { - val array = JSONArray() - items.take(24).forEach { item -> - array.put( - JSONObject() - .put("id", item.id) - .put("barcode", item.barcode) - .put("name", item.name) - .put("width", item.width) - .put("height", item.height) - .put("color", item.color) - .put("page", item.page) - .put("bmpPath", item.bmpPath) - .put("timestamp", item.timestamp), - ) - } - prefs.edit().putString("items", array.toString()).apply() - } -} diff --git a/android-companion/app/src/main/java/xyz/i12bp8/tagtinker/android/data/Profiles.kt b/android-companion/app/src/main/java/xyz/i12bp8/tagtinker/android/data/Profiles.kt deleted file mode 100644 index 383d0b1..0000000 --- a/android-companion/app/src/main/java/xyz/i12bp8/tagtinker/android/data/Profiles.kt +++ /dev/null @@ -1,80 +0,0 @@ -package xyz.i12bp8.tagtinker.android.data - -import xyz.i12bp8.tagtinker.android.model.TagProfile -import xyz.i12bp8.tagtinker.android.model.TagRecord - -private val tagProfiles = listOf( - TagProfile(1275, 320, 192, "graphic", "mono", "DM110"), - TagProfile(1276, 320, 140, "graphic", "mono", "DM90"), - TagProfile(1300, 172, 72, "graphic", "mono", "DM3370"), - TagProfile(1314, 400, 300, "graphic", "mono", "SmartTag HD110"), - TagProfile(1315, 296, 128, "graphic", "mono", "SmartTag HD L"), - TagProfile(1317, 152, 152, "graphic", "mono", "SmartTag HD S"), - TagProfile(1318, 208, 112, "graphic", "mono", "SmartTag HD M"), - TagProfile(1319, 800, 480, "graphic", "mono", "SmartTag HD200"), - TagProfile(1322, 152, 152, "graphic", "mono", "SmartTag HD S"), - TagProfile(1324, 208, 112, "graphic", "mono", "SmartTag HD M FZ"), - TagProfile(1327, 208, 112, "graphic", "red", "SmartTag HD M Red"), - TagProfile(1328, 296, 128, "graphic", "red", "SmartTag HD L Red"), - TagProfile(1336, 400, 300, "graphic", "red", "SmartTag HD110 Red"), - TagProfile(1339, 152, 152, "graphic", "red", "SmartTag HD S Red"), - TagProfile(1340, 800, 480, "graphic", "red", "SmartTag HD200 Red"), - TagProfile(1344, 296, 128, "graphic", "yellow", "SmartTag HD L Yellow"), - TagProfile(1346, 800, 480, "graphic", "yellow", "SmartTag HD200 Yellow"), - TagProfile(1348, 264, 176, "graphic", "red", "SmartTag HD T Red"), - TagProfile(1349, 264, 176, "graphic", "yellow", "SmartTag HD T Yellow"), - TagProfile(1351, 648, 480, "graphic", "mono", "SmartTag HD150"), - TagProfile(1353, 648, 480, "graphic", "red", "SmartTag HD150 Red"), - TagProfile(1370, 296, 128, "graphic", "red", "SmartTag HD L Red 2021"), - TagProfile(1371, 648, 480, "graphic", "red", "SmartTag HD150 Red 2021"), - TagProfile(1627, 296, 128, "graphic", "red", "SmartTag HD L Red"), - TagProfile(1628, 296, 128, "graphic", "red", "SmartTag HD L Red"), - TagProfile(1639, 152, 152, "graphic", "red", "SmartTag HD S Red"), -) - -fun normalizeBarcode(raw: String): String { - val compact = raw.filter { it.isLetterOrDigit() }.uppercase() - return when { - compact.length >= 17 && compact.firstOrNull()?.isLetter() == true -> compact.take(17) - compact.length == 16 && compact.all { it.isDigit() } -> "N$compact" - else -> compact.take(17) - } -} - -fun isValidBarcode(barcode: String): Boolean { - return barcode.length == 17 && - barcode.first().isLetter() && - barcode.drop(1).all { it.isDigit() } -} - -fun fallbackProfile(type: Int = 0): TagProfile { - return TagProfile( - type = type, - width = 296, - height = 128, - kind = "graphic", - color = "mono", - model = if (type == 0) "Unknown graphic tag" else "Unknown $type", - known = false, - ) -} - -fun profileFromBarcode(barcode: String): TagProfile { - if (!isValidBarcode(barcode)) return fallbackProfile() - val type = barcode.substring(12, 16).toIntOrNull() ?: 0 - return tagProfiles.firstOrNull { it.type == type } ?: fallbackProfile(type) -} - -fun tagFromBarcode(raw: String): TagRecord { - val barcode = normalizeBarcode(raw) - val profile = profileFromBarcode(barcode) - return TagRecord( - barcode = barcode, - name = profile.model.ifBlank { barcode }, - width = profile.width, - height = profile.height, - color = profile.color, - model = profile.model, - type = profile.type, - ) -} diff --git a/android-companion/app/src/main/java/xyz/i12bp8/tagtinker/android/model/TagModels.kt b/android-companion/app/src/main/java/xyz/i12bp8/tagtinker/android/model/TagModels.kt deleted file mode 100644 index 76d6d39..0000000 --- a/android-companion/app/src/main/java/xyz/i12bp8/tagtinker/android/model/TagModels.kt +++ /dev/null @@ -1,74 +0,0 @@ -package xyz.i12bp8.tagtinker.android.model - -import java.util.UUID - -data class TagProfile( - val type: Int, - val width: Int, - val height: Int, - val kind: String, - val color: String, - val model: String, - val known: Boolean = true, -) - -data class TagRecord( - val id: String = UUID.randomUUID().toString(), - val barcode: String, - val name: String, - val width: Int, - val height: Int, - val color: String, - val model: String, - val type: Int, -) - -enum class FitMode { Fit, Fill, Stretch } - -enum class DitherMode { Floyd, Atkinson, Threshold } - -data class EditorState( - val fitMode: FitMode = FitMode.Fit, - val ditherMode: DitherMode = DitherMode.Floyd, - val detail: Int = 60, - val scale: Float = 1f, - val offsetX: Float = 0f, - val offsetY: Float = 0f, - val contrast: Int = 8, - val brightness: Int = 0, - val useColor: Boolean = false, - val page: Int = 1, -) - -data class PayloadHistoryItem( - val id: String, - val barcode: String, - val name: String, - val width: Int, - val height: Int, - val color: String, - val page: Int, - val bmpPath: String, - val timestamp: Long, -) - -data class BlePeripheral( - val address: String, - val name: String, - val rssi: Int, -) - -data class UploadJob( - val jobId: String, - val barcode: String, - val width: Int, - val height: Int, - val page: Int, - val bmpBytes: ByteArray, -) - -data class UploadProgress( - val bytesDone: Int, - val bytesTotal: Int, - val label: String, -) diff --git a/android-companion/app/src/main/java/xyz/i12bp8/tagtinker/android/render/ImagePipeline.kt b/android-companion/app/src/main/java/xyz/i12bp8/tagtinker/android/render/ImagePipeline.kt deleted file mode 100644 index f29ff56..0000000 --- a/android-companion/app/src/main/java/xyz/i12bp8/tagtinker/android/render/ImagePipeline.kt +++ /dev/null @@ -1,275 +0,0 @@ -package xyz.i12bp8.tagtinker.android.render - -import android.graphics.Bitmap -import android.graphics.Canvas -import android.graphics.Color -import android.graphics.Matrix -import android.graphics.Paint -import kotlin.math.max -import kotlin.math.min -import xyz.i12bp8.tagtinker.android.model.DitherMode -import xyz.i12bp8.tagtinker.android.model.EditorState -import xyz.i12bp8.tagtinker.android.model.FitMode - -private val white = intArrayOf(255, 255, 255) -private val black = intArrayOf(0, 0, 0) -private val red = intArrayOf(218, 45, 53) -private val yellow = intArrayOf(226, 177, 32) - -data class RenderResult( - val bitmap: Bitmap, - val bmpBytes: ByteArray, -) - -object ImagePipeline { - fun render(source: Bitmap, width: Int, height: Int, accentColor: String, editor: EditorState): RenderResult { - val framed = drawSourceFrame(source, width, height, editor) - val adjusted = adjustBitmap(framed, editor) - val paletted = quantize(adjusted, accentColor, editor) - return RenderResult( - bitmap = paletted, - bmpBytes = buildBmp(paletted, accentColor != "mono" && editor.useColor), - ) - } - - private fun drawSourceFrame(source: Bitmap, width: Int, height: Int, editor: EditorState): Bitmap { - val output = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888) - val canvas = Canvas(output) - canvas.drawColor(Color.WHITE) - - val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply { - isFilterBitmap = true - } - - if (editor.fitMode == FitMode.Stretch) { - canvas.drawBitmap(source, null, android.graphics.Rect(0, 0, width, height), paint) - return output - } - - val scaleFactor = when (editor.fitMode) { - FitMode.Fill -> max(width / source.width.toFloat(), height / source.height.toFloat()) - else -> min(width / source.width.toFloat(), height / source.height.toFloat()) - } * editor.scale - - val drawWidth = max(1f, source.width * scaleFactor) - val drawHeight = max(1f, source.height * scaleFactor) - val left = (width - drawWidth) / 2f + editor.offsetX * width * 0.5f - val top = (height - drawHeight) / 2f + editor.offsetY * height * 0.5f - val matrix = Matrix().apply { - postScale(drawWidth / source.width, drawHeight / source.height) - postTranslate(left, top) - } - canvas.drawBitmap(source, matrix, paint) - return output - } - - private fun adjustBitmap(bitmap: Bitmap, editor: EditorState): Bitmap { - val out = bitmap.copy(Bitmap.Config.ARGB_8888, true) - val pixels = IntArray(out.width * out.height) - out.getPixels(pixels, 0, out.width, 0, 0, out.width, out.height) - val contrast = editor.contrast - val brightness = editor.brightness - val factor = (259f * (contrast + 255)) / (255 * (259 - contrast)) - for (i in pixels.indices) { - val color = pixels[i] - val r = clamp(factor * (Color.red(color) - 128) + 128 + brightness) - val g = clamp(factor * (Color.green(color) - 128) + 128 + brightness) - val b = clamp(factor * (Color.blue(color) - 128) + 128 + brightness) - pixels[i] = Color.argb(255, r, g, b) - } - out.setPixels(pixels, 0, out.width, 0, 0, out.width, out.height) - return out - } - - private fun quantize(bitmap: Bitmap, accentColor: String, editor: EditorState): Bitmap { - val width = bitmap.width - val height = bitmap.height - val src = IntArray(width * height) - bitmap.getPixels(src, 0, width, 0, 0, width, height) - val out = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888) - val dst = IntArray(width * height) - val palette = if (editor.useColor && accentColor != "mono") { - arrayOf(white, black, if (accentColor == "yellow") yellow else red) - } else { - arrayOf(white, black) - } - - if (editor.ditherMode == DitherMode.Threshold) { - src.indices.forEach { index -> - dst[index] = nearestColor(src[index], palette, editor.detail) - } - } else { - val work = FloatArray(width * height * 3) - src.indices.forEach { index -> - work[index * 3] = Color.red(src[index]).toFloat() - work[index * 3 + 1] = Color.green(src[index]).toFloat() - work[index * 3 + 2] = Color.blue(src[index]).toFloat() - } - for (y in 0 until height) { - for (x in 0 until width) { - val base = (y * width + x) * 3 - val current = Color.rgb( - clamp(work[base]), - clamp(work[base + 1]), - clamp(work[base + 2]), - ) - val next = nearestColor(current, palette, editor.detail) - dst[y * width + x] = next - val er = Color.red(current) - Color.red(next) - val eg = Color.green(current) - Color.green(next) - val eb = Color.blue(current) - Color.blue(next) - diffuse(work, width, height, x, y, er, eg, eb, editor.ditherMode) - } - } - } - - out.setPixels(dst, 0, width, 0, 0, width, height) - return out - } - - private fun diffuse( - work: FloatArray, - width: Int, - height: Int, - x: Int, - y: Int, - er: Int, - eg: Int, - eb: Int, - mode: DitherMode, - ) { - val points = if (mode == DitherMode.Atkinson) { - listOf( - intArrayOf(1, 0, 1, 8), - intArrayOf(2, 0, 1, 8), - intArrayOf(-1, 1, 1, 8), - intArrayOf(0, 1, 1, 8), - intArrayOf(1, 1, 1, 8), - intArrayOf(0, 2, 1, 8), - ) - } else { - listOf( - intArrayOf(1, 0, 7, 16), - intArrayOf(-1, 1, 3, 16), - intArrayOf(0, 1, 5, 16), - intArrayOf(1, 1, 1, 16), - ) - } - - points.forEach { point -> - val nx = x + point[0] - val ny = y + point[1] - if (nx !in 0 until width || ny !in 0 until height) return@forEach - val factor = point[2].toFloat() / point[3] - val base = (ny * width + nx) * 3 - work[base] += er * factor - work[base + 1] += eg * factor - work[base + 2] += eb * factor - } - } - - private fun nearestColor(color: Int, palette: Array, detail: Int): Int { - val threshold = 128 + (detail - 50) * 1.18f - if (palette.size == 2) { - val luma = Color.red(color) * 0.299f + Color.green(color) * 0.587f + Color.blue(color) * 0.114f - return if (luma >= threshold) Color.WHITE else Color.BLACK - } - - var best = palette.first() - var bestDist = Float.MAX_VALUE - palette.forEach { candidate -> - val dr = Color.red(color) - candidate[0] - val dg = Color.green(color) - candidate[1] - val db = Color.blue(color) - candidate[2] - val dist = dr * dr * 0.92f + dg * dg + db * db * 0.86f - if (dist < bestDist) { - bestDist = dist - best = candidate - } - } - return Color.rgb(best[0], best[1], best[2]) - } - - private fun buildBmp(bitmap: Bitmap, accent: Boolean): ByteArray { - val width = bitmap.width - val height = bitmap.height - val rowStride = ((width + 31) / 32) * 4 - val colorStride = if (accent) rowStride else 0 - val pixelBytes = (rowStride + colorStride) * height - val paletteBytes = if (accent) 12 else 8 - val headerSize = 14 + 40 + paletteBytes - val fileSize = headerSize + pixelBytes - val bytes = ByteArray(fileSize) - - writeAscii(bytes, 0, "BM") - writeIntLE(bytes, 2, fileSize) - writeIntLE(bytes, 10, headerSize) - writeIntLE(bytes, 14, 40) - writeIntLE(bytes, 18, width) - writeIntLE(bytes, 22, height) - writeShortLE(bytes, 26, 1) - writeShortLE(bytes, 28, if (accent) 2 else 1) - writeIntLE(bytes, 34, pixelBytes) - - var paletteOffset = 54 - writePalette(bytes, paletteOffset, Color.WHITE) - paletteOffset += 4 - writePalette(bytes, paletteOffset, Color.BLACK) - if (accent) { - paletteOffset += 4 - var accentColor = Color.RED - for (y in 0 until height) { - for (x in 0 until width) { - val c = bitmap.getPixel(x, y) - if (c != Color.WHITE && c != Color.BLACK) accentColor = c - } - } - writePalette(bytes, paletteOffset, accentColor) - } - - val monoOffset = headerSize - val accentOffset = headerSize + rowStride * height - - for (y in 0 until height) { - val flippedY = height - 1 - y - for (x in 0 until width) { - val color = bitmap.getPixel(x, flippedY) - val byteIndex = x / 8 - val bit = 7 - (x % 8) - if (color == Color.BLACK) { - bytes[monoOffset + y * rowStride + byteIndex] = - (bytes[monoOffset + y * rowStride + byteIndex].toInt() or (1 shl bit)).toByte() - } else if (accent && color != Color.WHITE) { - bytes[accentOffset + y * rowStride + byteIndex] = - (bytes[accentOffset + y * rowStride + byteIndex].toInt() or (1 shl bit)).toByte() - } - } - } - return bytes - } - - private fun writeAscii(bytes: ByteArray, offset: Int, value: String) { - value.encodeToByteArray().copyInto(bytes, offset) - } - - private fun writeShortLE(bytes: ByteArray, offset: Int, value: Int) { - bytes[offset] = (value and 0xff).toByte() - bytes[offset + 1] = ((value shr 8) and 0xff).toByte() - } - - private fun writeIntLE(bytes: ByteArray, offset: Int, value: Int) { - bytes[offset] = (value and 0xff).toByte() - bytes[offset + 1] = ((value shr 8) and 0xff).toByte() - bytes[offset + 2] = ((value shr 16) and 0xff).toByte() - bytes[offset + 3] = ((value shr 24) and 0xff).toByte() - } - - private fun writePalette(bytes: ByteArray, offset: Int, color: Int) { - bytes[offset] = Color.blue(color).toByte() - bytes[offset + 1] = Color.green(color).toByte() - bytes[offset + 2] = Color.red(color).toByte() - bytes[offset + 3] = 0 - } - - private fun clamp(value: Float): Int = value.toInt().coerceIn(0, 255) -} diff --git a/android-companion/app/src/main/res/values/themes.xml b/android-companion/app/src/main/res/values/themes.xml deleted file mode 100644 index 5cd98f7..0000000 --- a/android-companion/app/src/main/res/values/themes.xml +++ /dev/null @@ -1,7 +0,0 @@ - - - - diff --git a/android-companion/app/tagtinker.keystore b/android-companion/app/tagtinker.keystore deleted file mode 100644 index f6db6ee..0000000 Binary files a/android-companion/app/tagtinker.keystore and /dev/null differ diff --git a/android-companion/build.gradle.kts b/android-companion/build.gradle.kts deleted file mode 100644 index 1b4481a..0000000 --- a/android-companion/build.gradle.kts +++ /dev/null @@ -1,5 +0,0 @@ -plugins { - id("com.android.application") version "8.7.3" apply false - id("org.jetbrains.kotlin.android") version "2.0.21" apply false - id("org.jetbrains.kotlin.plugin.compose") version "2.0.21" apply false -} diff --git a/android-companion/gradle.properties b/android-companion/gradle.properties deleted file mode 100644 index 5f4cff3..0000000 --- a/android-companion/gradle.properties +++ /dev/null @@ -1,4 +0,0 @@ -org.gradle.jvmargs=-Xmx2g -Dfile.encoding=UTF-8 -android.useAndroidX=true -android.nonTransitiveRClass=true -kotlin.code.style=official diff --git a/android-companion/gradle/wrapper/gradle-wrapper.jar b/android-companion/gradle/wrapper/gradle-wrapper.jar deleted file mode 100644 index f6b961f..0000000 Binary files a/android-companion/gradle/wrapper/gradle-wrapper.jar and /dev/null differ diff --git a/android-companion/gradle/wrapper/gradle-wrapper.properties b/android-companion/gradle/wrapper/gradle-wrapper.properties deleted file mode 100644 index d06a5ce..0000000 --- a/android-companion/gradle/wrapper/gradle-wrapper.properties +++ /dev/null @@ -1,6 +0,0 @@ -#Thu Aug 08 15:05:08 CEST 2019 -distributionBase=GRADLE_USER_HOME -distributionPath=wrapper/dists -zipStoreBase=GRADLE_USER_HOME -zipStorePath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-bin.zip diff --git a/android-companion/gradlew b/android-companion/gradlew deleted file mode 100755 index cccdd3d..0000000 --- a/android-companion/gradlew +++ /dev/null @@ -1,172 +0,0 @@ -#!/usr/bin/env sh - -############################################################################## -## -## Gradle start up script for UN*X -## -############################################################################## - -# Attempt to set APP_HOME -# Resolve links: $0 may be a link -PRG="$0" -# Need this for relative symlinks. -while [ -h "$PRG" ] ; do - ls=`ls -ld "$PRG"` - link=`expr "$ls" : '.*-> \(.*\)$'` - if expr "$link" : '/.*' > /dev/null; then - PRG="$link" - else - PRG=`dirname "$PRG"`"/$link" - fi -done -SAVED="`pwd`" -cd "`dirname \"$PRG\"`/" >/dev/null -APP_HOME="`pwd -P`" -cd "$SAVED" >/dev/null - -APP_NAME="Gradle" -APP_BASE_NAME=`basename "$0"` - -# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. -DEFAULT_JVM_OPTS="" - -# Use the maximum available, or set MAX_FD != -1 to use that value. -MAX_FD="maximum" - -warn () { - echo "$*" -} - -die () { - echo - echo "$*" - echo - exit 1 -} - -# OS specific support (must be 'true' or 'false'). -cygwin=false -msys=false -darwin=false -nonstop=false -case "`uname`" in - CYGWIN* ) - cygwin=true - ;; - Darwin* ) - darwin=true - ;; - MINGW* ) - msys=true - ;; - NONSTOP* ) - nonstop=true - ;; -esac - -CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar - -# Determine the Java command to use to start the JVM. -if [ -n "$JAVA_HOME" ] ; then - if [ -x "$JAVA_HOME/jre/sh/java" ] ; then - # IBM's JDK on AIX uses strange locations for the executables - JAVACMD="$JAVA_HOME/jre/sh/java" - else - JAVACMD="$JAVA_HOME/bin/java" - fi - if [ ! -x "$JAVACMD" ] ; then - die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME - -Please set the JAVA_HOME variable in your environment to match the -location of your Java installation." - fi -else - JAVACMD="java" - which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. - -Please set the JAVA_HOME variable in your environment to match the -location of your Java installation." -fi - -# Increase the maximum file descriptors if we can. -if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then - MAX_FD_LIMIT=`ulimit -H -n` - if [ $? -eq 0 ] ; then - if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then - MAX_FD="$MAX_FD_LIMIT" - fi - ulimit -n $MAX_FD - if [ $? -ne 0 ] ; then - warn "Could not set maximum file descriptor limit: $MAX_FD" - fi - else - warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" - fi -fi - -# For Darwin, add options to specify how the application appears in the dock -if $darwin; then - GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" -fi - -# For Cygwin, switch paths to Windows format before running java -if $cygwin ; then - APP_HOME=`cygpath --path --mixed "$APP_HOME"` - CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` - JAVACMD=`cygpath --unix "$JAVACMD"` - - # We build the pattern for arguments to be converted via cygpath - ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` - SEP="" - for dir in $ROOTDIRSRAW ; do - ROOTDIRS="$ROOTDIRS$SEP$dir" - SEP="|" - done - OURCYGPATTERN="(^($ROOTDIRS))" - # Add a user-defined pattern to the cygpath arguments - if [ "$GRADLE_CYGPATTERN" != "" ] ; then - OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" - fi - # Now convert the arguments - kludge to limit ourselves to /bin/sh - i=0 - for arg in "$@" ; do - CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` - CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option - - if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition - eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` - else - eval `echo args$i`="\"$arg\"" - fi - i=$((i+1)) - done - case $i in - (0) set -- ;; - (1) set -- "$args0" ;; - (2) set -- "$args0" "$args1" ;; - (3) set -- "$args0" "$args1" "$args2" ;; - (4) set -- "$args0" "$args1" "$args2" "$args3" ;; - (5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; - (6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; - (7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; - (8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; - (9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; - esac -fi - -# Escape application args -save () { - for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done - echo " " -} -APP_ARGS=$(save "$@") - -# Collect all arguments for the java command, following the shell quoting and substitution rules -eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" - -# by default we should be in the correct project dir, but when run from Finder on Mac, the cwd is wrong -if [ "$(uname)" = "Darwin" ] && [ "$HOME" = "$PWD" ]; then - cd "$(dirname "$0")" -fi - -exec "$JAVACMD" "$@" diff --git a/android-companion/gradlew.bat b/android-companion/gradlew.bat deleted file mode 100644 index e95643d..0000000 --- a/android-companion/gradlew.bat +++ /dev/null @@ -1,84 +0,0 @@ -@if "%DEBUG%" == "" @echo off -@rem ########################################################################## -@rem -@rem Gradle startup script for Windows -@rem -@rem ########################################################################## - -@rem Set local scope for the variables with windows NT shell -if "%OS%"=="Windows_NT" setlocal - -set DIRNAME=%~dp0 -if "%DIRNAME%" == "" set DIRNAME=. -set APP_BASE_NAME=%~n0 -set APP_HOME=%DIRNAME% - -@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. -set DEFAULT_JVM_OPTS= - -@rem Find java.exe -if defined JAVA_HOME goto findJavaFromJavaHome - -set JAVA_EXE=java.exe -%JAVA_EXE% -version >NUL 2>&1 -if "%ERRORLEVEL%" == "0" goto init - -echo. -echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. -echo. -echo Please set the JAVA_HOME variable in your environment to match the -echo location of your Java installation. - -goto fail - -:findJavaFromJavaHome -set JAVA_HOME=%JAVA_HOME:"=% -set JAVA_EXE=%JAVA_HOME%/bin/java.exe - -if exist "%JAVA_EXE%" goto init - -echo. -echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% -echo. -echo Please set the JAVA_HOME variable in your environment to match the -echo location of your Java installation. - -goto fail - -:init -@rem Get command-line arguments, handling Windows variants - -if not "%OS%" == "Windows_NT" goto win9xME_args - -:win9xME_args -@rem Slurp the command line arguments. -set CMD_LINE_ARGS= -set _SKIP=2 - -:win9xME_args_slurp -if "x%~1" == "x" goto execute - -set CMD_LINE_ARGS=%* - -:execute -@rem Setup the command line - -set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar - -@rem Execute Gradle -"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS% - -:end -@rem End local scope for the variables with windows NT shell -if "%ERRORLEVEL%"=="0" goto mainEnd - -:fail -rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of -rem the _cmd.exe /c_ return code! -if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 -exit /b 1 - -:mainEnd -if "%OS%"=="Windows_NT" endlocal - -:omega diff --git a/android-companion/local.properties b/android-companion/local.properties deleted file mode 100644 index f96d6c6..0000000 --- a/android-companion/local.properties +++ /dev/null @@ -1 +0,0 @@ -sdk.dir=/home/i12bp8/Android/sdk \ No newline at end of file diff --git a/android-companion/settings.gradle.kts b/android-companion/settings.gradle.kts deleted file mode 100644 index 9f89def..0000000 --- a/android-companion/settings.gradle.kts +++ /dev/null @@ -1,18 +0,0 @@ -pluginManagement { - repositories { - google() - mavenCentral() - gradlePluginPortal() - } -} - -dependencyResolutionManagement { - repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) - repositories { - google() - mavenCentral() - } -} - -rootProject.name = "TagTinkerAndroid" -include(":app") diff --git a/scenes/tagtinker_scene_image_options.c b/scenes/tagtinker_scene_image_options.c index 6206a8b..2c0c006 100644 --- a/scenes/tagtinker_scene_image_options.c +++ b/scenes/tagtinker_scene_image_options.c @@ -2,61 +2,9 @@ enum { ImageOptionPage, - ImageOptionOffsetX, - ImageOptionOffsetY, - ImageOptionCompression, - ImageOptionFrameRepeat, ImageOptionTransmit, }; -static const char* image_option_compression_labels[] = {"Auto", "Raw", "RLE"}; -static const uint16_t image_option_coord_values[] = { - 0, 8, 16, 24, 32, 40, 48, 56, 64, 72, 80, 88, 96, 104, 112, 120, 128, - 136, 144, 152, 160, 168, 176, 184, 192, 200, 208, 216, 224, 232, 240, 248, 256, 264, - 272, 280, 288, 296, 304, 312, 320, 328, 336, 344, 352, 360, 368, 376, 384, 392, 400, - 408, 416, 424, 432, 440, 448, 456, 464, 472, 480, 488, 496, 504, 512, 520, 528, 536, - 544, 552, 560, 568, 576, 584, 592, 600, 608, 616, 624, 632, 640, 648, 656, 664, 672, - 680, 688, 696, 704, 712, 720, 728, 736, 744, 752, 760, 768, 776, 784, 792, 800, -}; - -#define IMAGE_OPTION_COORD_COUNT COUNT_OF(image_option_coord_values) - -static uint8_t image_option_nearest_coord_index(uint16_t value) { - uint8_t best_idx = 0; - uint16_t best_diff = UINT16_MAX; - - for(uint8_t i = 0; i < IMAGE_OPTION_COORD_COUNT; i++) { - uint16_t current = image_option_coord_values[i]; - uint16_t diff = (current > value) ? (current - value) : (value - current); - if(diff < best_diff) { - best_diff = diff; - best_idx = i; - } - } - - return best_idx; -} - -static void image_option_clamp_position(TagTinkerApp* app) { - if(!app) return; - if(app->selected_target < 0 || app->selected_target >= app->target_count) return; - - TagTinkerImageTxJob* job = &app->image_tx_job; - const TagTinkerTarget* target = &app->targets[app->selected_target]; - - if(!target->profile.known || !target->profile.width || !target->profile.height) return; - - uint16_t max_x = (job->width < target->profile.width) ? - (uint16_t)(target->profile.width - job->width) : - 0U; - uint16_t max_y = (job->height < target->profile.height) ? - (uint16_t)(target->profile.height - job->height) : - 0U; - - if(job->pos_x > max_x) job->pos_x = max_x; - if(job->pos_y > max_y) job->pos_y = max_y; -} - static void image_option_page_changed(VariableItem* item) { TagTinkerApp* app = variable_item_get_context(item); app->image_tx_job.page = variable_item_get_current_value_index(item); @@ -67,59 +15,11 @@ static void image_option_page_changed(VariableItem* item) { variable_item_set_current_value_text(item, buf); } -static void image_option_offset_x_changed(VariableItem* item) { - TagTinkerApp* app = variable_item_get_context(item); - uint8_t index = variable_item_get_current_value_index(item); - if(index >= IMAGE_OPTION_COORD_COUNT) index = IMAGE_OPTION_COORD_COUNT - 1U; - - app->image_tx_job.pos_x = image_option_coord_values[index]; - image_option_clamp_position(app); - app->draw_x = app->image_tx_job.pos_x; - - char buf[8]; - snprintf(buf, sizeof(buf), "%u", app->image_tx_job.pos_x); - variable_item_set_current_value_text(item, buf); -} - -static void image_option_offset_y_changed(VariableItem* item) { - TagTinkerApp* app = variable_item_get_context(item); - uint8_t index = variable_item_get_current_value_index(item); - if(index >= IMAGE_OPTION_COORD_COUNT) index = IMAGE_OPTION_COORD_COUNT - 1U; - - app->image_tx_job.pos_y = image_option_coord_values[index]; - image_option_clamp_position(app); - app->draw_y = app->image_tx_job.pos_y; - - char buf[8]; - snprintf(buf, sizeof(buf), "%u", app->image_tx_job.pos_y); - variable_item_set_current_value_text(item, buf); -} - -static void image_option_compression_changed(VariableItem* item) { - TagTinkerApp* app = variable_item_get_context(item); - uint8_t index = variable_item_get_current_value_index(item); - if(index > TagTinkerCompressionRle) index = TagTinkerCompressionRle; - - app->compression_mode = (TagTinkerCompressionMode)index; - variable_item_set_current_value_text(item, image_option_compression_labels[index]); -} - -static void image_option_frame_repeat_changed(VariableItem* item) { - TagTinkerApp* app = variable_item_get_context(item); - uint8_t index = variable_item_get_current_value_index(item); - app->data_frame_repeats = index + 1U; - - char buf[4]; - snprintf(buf, sizeof(buf), "%u", app->data_frame_repeats); - variable_item_set_current_value_text(item, buf); -} - static void image_option_enter_cb(void* ctx, uint32_t index) { TagTinkerApp* app = ctx; if(index != ImageOptionTransmit) return; - image_option_clamp_position(app); app->tx_spam = false; scene_manager_next_scene(app->scene_manager, TagTinkerSceneTransmit); } @@ -134,7 +34,6 @@ void tagtinker_scene_image_options_on_enter(void* ctx) { return; } - image_option_clamp_position(app); variable_item_list_reset(list); VariableItem* item = @@ -146,37 +45,14 @@ void tagtinker_scene_image_options_on_enter(void* ctx) { variable_item_set_current_value_text(item, buf); } - item = variable_item_list_add( - list, "Offset X", IMAGE_OPTION_COORD_COUNT, image_option_offset_x_changed, app); - variable_item_set_current_value_index(item, image_option_nearest_coord_index(job->pos_x)); - { - char buf[8]; - snprintf(buf, sizeof(buf), "%u", job->pos_x); - variable_item_set_current_value_text(item, buf); - } - - item = variable_item_list_add( - list, "Offset Y", IMAGE_OPTION_COORD_COUNT, image_option_offset_y_changed, app); - variable_item_set_current_value_index(item, image_option_nearest_coord_index(job->pos_y)); - { - char buf[8]; - snprintf(buf, sizeof(buf), "%u", job->pos_y); - variable_item_set_current_value_text(item, buf); - } - - item = variable_item_list_add( - list, "Compression", 3, image_option_compression_changed, app); - variable_item_set_current_value_index(item, app->compression_mode); - variable_item_set_current_value_text(item, image_option_compression_labels[app->compression_mode]); - - item = variable_item_list_add( - list, "Frame Repeat", 5, image_option_frame_repeat_changed, app); - variable_item_set_current_value_index(item, app->data_frame_repeats - 1U); - { - char buf[4]; - snprintf(buf, sizeof(buf), "%u", app->data_frame_repeats); - variable_item_set_current_value_text(item, buf); - } + /* Position, compression and frame-repeat are intentionally fixed here: + * pages are the only knob that meaningfully changes per-image, the rest + * are best left at their defaults (top-left, auto RLE, x2 frame repeat). + * Power users can still tune them in Settings. */ + job->pos_x = 0; + job->pos_y = 0; + app->draw_x = 0; + app->draw_y = 0; variable_item_list_add(list, ">> Send BMP <<", 0, NULL, app); variable_item_list_set_enter_callback(list, image_option_enter_cb, app); diff --git a/scenes/tagtinker_scene_main_menu.c b/scenes/tagtinker_scene_main_menu.c index d6ad526..d42db95 100644 --- a/scenes/tagtinker_scene_main_menu.c +++ b/scenes/tagtinker_scene_main_menu.c @@ -8,7 +8,6 @@ enum { MainMenuBroadcast, MainMenuTargetESL, MainMenuSettings, - MainMenuPhoneSync, MainMenuAbout, }; @@ -25,7 +24,6 @@ void tagtinker_scene_main_menu_on_enter(void* ctx) { submenu_add_item(app->submenu, "Broadcast Payloads", MainMenuBroadcast, main_menu_cb, app); submenu_add_item(app->submenu, "Targeted Payloads", MainMenuTargetESL, main_menu_cb, app); - submenu_add_item(app->submenu, "Phone Sync (Custom Images)", MainMenuPhoneSync, main_menu_cb, app); submenu_add_item(app->submenu, "Settings", MainMenuSettings, main_menu_cb, app); submenu_add_item(app->submenu, "About", MainMenuAbout, main_menu_cb, app); @@ -52,11 +50,6 @@ bool tagtinker_scene_main_menu_on_event(void* ctx, SceneManagerEvent event) { case MainMenuSettings: scene_manager_next_scene(app->scene_manager, TagTinkerSceneSettings); return true; - case MainMenuPhoneSync: - /* state=1 tells About scene to show the browser sync receiver */ - scene_manager_set_scene_state(app->scene_manager, TagTinkerSceneAbout, 1); - scene_manager_next_scene(app->scene_manager, TagTinkerSceneAbout); - return true; case MainMenuAbout: scene_manager_set_scene_state(app->scene_manager, TagTinkerSceneAbout, 0); scene_manager_next_scene(app->scene_manager, TagTinkerSceneAbout); diff --git a/scenes/tagtinker_scene_synced_image_list.c b/scenes/tagtinker_scene_synced_image_list.c index 3b3fc50..5e4bdeb 100644 --- a/scenes/tagtinker_scene_synced_image_list.c +++ b/scenes/tagtinker_scene_synced_image_list.c @@ -1,9 +1,117 @@ #include "../tagtinker_app.h" #define EVT_SYNCED_IMAGE_BASE 300 +#define TAGTINKER_DROPPED_DIR APP_DATA_PATH("dropped") + +/* Build a synced image entry from any .bmp file in the dropped/ folder. The + * Flipper rescales BMPs at send time (see tagtinker_scene_transmit.c), so any + * BMP can target any tag - the entry's width/height are the *target* dims, not + * the source dims, and tx_stream_bmp_image samples the source pixels via + * nearest-neighbour as it streams. + * + * Filenames produced by web-image-prep are "x[_