├── .gitignore ├── .idea ├── .gitignore ├── .name ├── compiler.xml ├── deploymentTargetSelector.xml ├── gradle.xml ├── inspectionProfiles │ └── Project_Default.xml ├── kotlinc.xml ├── ktfmt.xml ├── migrations.xml ├── misc.xml ├── other.xml ├── runConfigurations.xml └── vcs.xml ├── LICENSE ├── README.md ├── app ├── .gitignore ├── build.gradle.kts ├── proguard-rules.pro └── src │ ├── androidTest │ └── java │ │ └── com │ │ └── kormax │ │ └── observemodedemo │ │ └── ExampleInstrumentedTest.kt │ ├── main │ ├── AndroidManifest.xml │ ├── java │ │ └── com │ │ │ └── kormax │ │ │ └── observemodedemo │ │ │ ├── MainActivity.kt │ │ │ ├── ObserveModeHostApduService.kt │ │ │ ├── Utilities.kt │ │ │ └── ui │ │ │ └── theme │ │ │ ├── Color.kt │ │ │ ├── Theme.kt │ │ │ └── Type.kt │ └── res │ │ ├── drawable │ │ ├── ic_launcher_background.xml │ │ └── ic_launcher_foreground.xml │ │ ├── mipmap-anydpi │ │ ├── ic_launcher.xml │ │ └── ic_launcher_round.xml │ │ ├── mipmap-hdpi │ │ ├── ic_launcher.webp │ │ └── ic_launcher_round.webp │ │ ├── mipmap-mdpi │ │ ├── ic_launcher.webp │ │ └── ic_launcher_round.webp │ │ ├── mipmap-xhdpi │ │ ├── ic_launcher.webp │ │ └── ic_launcher_round.webp │ │ ├── mipmap-xxhdpi │ │ ├── ic_launcher.webp │ │ └── ic_launcher_round.webp │ │ ├── mipmap-xxxhdpi │ │ ├── ic_launcher.webp │ │ └── ic_launcher_round.webp │ │ ├── values │ │ ├── colors.xml │ │ ├── strings.xml │ │ └── themes.xml │ │ └── xml │ │ ├── apduservice.xml │ │ ├── backup_rules.xml │ │ └── data_extraction_rules.xml │ └── test │ └── java │ └── com │ └── kormax │ └── observemodedemo │ └── ExampleUnitTest.kt ├── assets ├── SCREENSHOT.HISTORY.webp ├── SCREENSHOT.LOOP.DELTA.webp └── SCREENSHOT.LOOP.webp ├── build.gradle.kts ├── gradle.properties ├── gradle ├── libs.versions.toml └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradlew ├── gradlew.bat └── settings.gradle.kts /.gitignore: -------------------------------------------------------------------------------- 1 | *.iml 2 | .gradle 3 | /local.properties 4 | /.idea/caches 5 | /.idea/libraries 6 | /.idea/modules.xml 7 | /.idea/workspace.xml 8 | /.idea/navEditor.xml 9 | /.idea/assetWizardSettings.xml 10 | .DS_Store 11 | /build 12 | /captures 13 | .externalNativeBuild 14 | .cxx 15 | local.properties 16 | -------------------------------------------------------------------------------- /.idea/.gitignore: -------------------------------------------------------------------------------- 1 | # Default ignored files 2 | /shelf/ 3 | /workspace.xml 4 | -------------------------------------------------------------------------------- /.idea/.name: -------------------------------------------------------------------------------- 1 | observe-mode-demo -------------------------------------------------------------------------------- /.idea/compiler.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /.idea/deploymentTargetSelector.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /.idea/gradle.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 19 | 20 | -------------------------------------------------------------------------------- /.idea/inspectionProfiles/Project_Default.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 68 | -------------------------------------------------------------------------------- /.idea/kotlinc.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 6 | -------------------------------------------------------------------------------- /.idea/ktfmt.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 8 | -------------------------------------------------------------------------------- /.idea/migrations.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 9 | 10 | -------------------------------------------------------------------------------- /.idea/misc.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 10 | -------------------------------------------------------------------------------- /.idea/other.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 262 | 263 | -------------------------------------------------------------------------------- /.idea/runConfigurations.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 16 | 17 | -------------------------------------------------------------------------------- /.idea/vcs.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 kormax 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Android 15 Observe Mode Demo 2 | 3 |

4 | ![Screenshot with history of polling events displayed] 5 | ![Screenshot with polling loop of an iPhone displayed] 6 | ![Screenshot with polling loop of an HID reader displayed] 7 |

8 | 9 | 10 | # Overview 11 | 12 | This project provides a demonstration of [Observe Mode](https://developer.android.com/reference/android/nfc/NfcAdapter#isObserveModeSupported()) feature introduced in Android 15, which enables NFC hardware to listen to reader polling without responding to it. 13 | 14 | The application allows a user to look at the polling frames sent by the reader, with the following data: 15 | * Type (A, B, F, U*, ON, OFF); 16 | * Delta, the amount of time since a previous polling loop frame; 17 | * Adjusted gain value; 18 | * Payload data. 19 | * Name if applicable; 20 | 21 | A user can choose between two display modes: 22 | * Historical data. Displays a full list of all polling loop events, including field activity events and all polling frames; 23 | * Loop data. Displays a unique part of the polling loop generated by a reader based on the detected repeated pattern. 24 | 25 | 26 | # Requirements 27 | 28 | * Android Studio Preview; 29 | * Android VanillaIceCream SDK; 30 | * A compatible Google Pixel Phone, with Android 15 Beta 1.1 or newer installed; 31 | * Any other mobile device or an NFC reader, in order to look at the polling frame data. 32 | 33 | 34 | # Known issues 35 | 36 | * ~~When re-installing the app, Observe Mode might stop working until it is manually turned on and off a couple of times (this could be an issue caused by this app)~~ (Fixed in Beta 2); 37 | * ~~When launching the app initially, there could be a significant delay before the system starts notifying the app about the incoming polling loop events~~ (Fixed in Beta 2); 38 | * [Depending on NFC type and data payload, reported polling frame data may be missing, mutated, or have a mislabeled type](https://issuetracker.google.com/issues/334298675): 39 | - Type A: 40 | - ~~Custom frame data is always missing for long frames with or without CRC~~ (Fixed in Beta 2); 41 | - Common seven-bit short frames like WUPA or REQA return `52` + some extra bytes of data containing previous polling frames as data instead of just `52` or `26`; 42 | - ~~Custom seven-bit short frames return no data~~ (Fixed in Beta 2). 43 | - Type B: 44 | - Proper frames with CRC are detected as type B frames with proper data. 45 | - Frames with improper CRC are detected as type U and have an extra byte containing length inside of data part. 46 | - Type F: 47 | - No issues as of Beta 2. 48 | - Type V: 49 | - Type V frames are unsupported by this API, so they are missing, which is the intended behavior. 50 | 51 | 52 | # Potential improvements 53 | 54 | * ~~Improve the UI by displaying polling frames inside of the RF field activity block instead of displaying field ON and OFF events as separate blocks.~~; 55 | * Add an ability to parse out and display additional information about the polling frame (WIP): 56 | * ~~Polling frame names, including custom ones~~; 57 | * Type B: 58 | * Timeslot count; 59 | * AFI. 60 | * Type F: 61 | * System code; 62 | * Timeslot count; 63 | * Request code. 64 | * Improve overall code quality: 65 | * Refactor project structure, break up modules; 66 | * Optimize code; 67 | * ~~Add an ability to enable/disable observe mode with a click of a button, allowing the device to respond to a reader if needed~~; 68 | * Improved interaction with the NFC service: 69 | * Refresh current NFC configuration state not only upon resume or start; 70 | * Dynamically update list of encountered errors, info about the current state. 71 | * ~~Ability to detect repeating polling loop patterns (frame order, type, data, delta, field events), and provide an option to only display the unique part instead of the whole history~~: 72 | * Improve polling loop pattern detection stability and performance, as currently a successful detection requires 2 full loops, the algorithm is pretty naive, and there could still be unmanaged corner cases with very weird loops. 73 | * Using the ability to find out polling loop patterns, add an ability to classify/detect specific readers based on this information; 74 | * Improve user interface. 75 | 76 | 77 | # Notes 78 | 79 | * This project has been created without much prior experience with Android development. In case you have found an issue with the app, or can propose an improvement to the source code, feel free to raise an Issue or create a Pull Request. 80 | 81 | 82 | # References 83 | 84 | * [Android Developers - NfcAdapter](https://developer.android.com/reference/android/nfc/NfcAdapter); 85 | * [Android Developers - CardEmulation](https://developer.android.com/reference/android/nfc/cardemulation/CardEmulation); 86 | * [Android Developers - HostApduService](https://developer.android.com/reference/android/nfc/cardemulation/HostApduService); 87 | -------------------------------------------------------------------------------- /app/.gitignore: -------------------------------------------------------------------------------- 1 | /build -------------------------------------------------------------------------------- /app/build.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | alias(libs.plugins.android.application) 3 | alias(libs.plugins.jetbrains.kotlin.android) 4 | id("kotlin-parcelize") 5 | } 6 | 7 | android { 8 | namespace = "com.kormax.observemodedemo" 9 | compileSdk = 35 10 | 11 | defaultConfig { 12 | applicationId = "com.kormax.observemodedemo" 13 | minSdk = 35 14 | targetSdk = 35 15 | versionCode = 1 16 | versionName = "1.0" 17 | 18 | testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" 19 | vectorDrawables { 20 | useSupportLibrary = true 21 | } 22 | } 23 | 24 | buildTypes { 25 | release { 26 | isMinifyEnabled = false 27 | proguardFiles( 28 | getDefaultProguardFile("proguard-android-optimize.txt"), 29 | "proguard-rules.pro" 30 | ) 31 | } 32 | } 33 | compileOptions { 34 | sourceCompatibility = JavaVersion.VERSION_11 35 | targetCompatibility = JavaVersion.VERSION_11 36 | } 37 | kotlinOptions { 38 | jvmTarget = "11" 39 | 40 | freeCompilerArgs += arrayListOf( 41 | "-Xopt-in=kotlin.ExperimentalUnsignedTypes", 42 | "-Xopt-in=kotlin.ExperimentalStdlibApi", 43 | "-Xopt-in=androidx.compose.material3.ExperimentalMaterial3Api" 44 | ) 45 | } 46 | buildFeatures { 47 | compose = true 48 | } 49 | composeOptions { 50 | kotlinCompilerExtensionVersion = "1.5.1" 51 | } 52 | packaging { 53 | resources { 54 | excludes += "/META-INF/{AL2.0,LGPL2.1}" 55 | } 56 | } 57 | } 58 | 59 | dependencies { 60 | 61 | implementation(libs.androidx.core.ktx) 62 | implementation(libs.androidx.lifecycle.runtime.ktx) 63 | implementation(libs.androidx.activity.compose) 64 | implementation(platform(libs.androidx.compose.bom)) 65 | implementation(libs.androidx.ui) 66 | implementation(libs.androidx.ui.graphics) 67 | implementation(libs.androidx.ui.tooling.preview) 68 | implementation(libs.androidx.material3) 69 | implementation(libs.androidx.palette) 70 | testImplementation(libs.junit) 71 | androidTestImplementation(libs.androidx.junit) 72 | androidTestImplementation(libs.androidx.espresso.core) 73 | androidTestImplementation(platform(libs.androidx.compose.bom)) 74 | androidTestImplementation(libs.androidx.ui.test.junit4) 75 | debugImplementation(libs.androidx.ui.tooling) 76 | debugImplementation(libs.androidx.ui.test.manifest) 77 | } -------------------------------------------------------------------------------- /app/proguard-rules.pro: -------------------------------------------------------------------------------- 1 | # Add project specific ProGuard rules here. 2 | # You can control the set of applied configuration files using the 3 | # proguardFiles setting in build.gradle. 4 | # 5 | # For more details, see 6 | # http://developer.android.com/guide/developing/tools/proguard.html 7 | 8 | # If your project uses WebView with JS, uncomment the following 9 | # and specify the fully qualified class name to the JavaScript interface 10 | # class: 11 | #-keepclassmembers class fqcn.of.javascript.interface.for.webview { 12 | # public *; 13 | #} 14 | 15 | # Uncomment this to preserve the line number information for 16 | # debugging stack traces. 17 | #-keepattributes SourceFile,LineNumberTable 18 | 19 | # If you keep the line number information, uncomment this to 20 | # hide the original source file name. 21 | #-renamesourcefileattribute SourceFile -------------------------------------------------------------------------------- /app/src/androidTest/java/com/kormax/observemodedemo/ExampleInstrumentedTest.kt: -------------------------------------------------------------------------------- 1 | package com.kormax.observemodedemo 2 | 3 | import androidx.test.platform.app.InstrumentationRegistry 4 | import androidx.test.ext.junit.runners.AndroidJUnit4 5 | 6 | import org.junit.Test 7 | import org.junit.runner.RunWith 8 | 9 | import org.junit.Assert.* 10 | 11 | /** 12 | * Instrumented test, which will execute on an Android device. 13 | * 14 | * See [testing documentation](http://d.android.com/tools/testing). 15 | */ 16 | @RunWith(AndroidJUnit4::class) 17 | class ExampleInstrumentedTest { 18 | @Test 19 | fun useAppContext() { 20 | // Context of the app under test. 21 | val appContext = InstrumentationRegistry.getInstrumentation().targetContext 22 | assertEquals("com.kormax.observemodedemo", appContext.packageName) 23 | } 24 | } -------------------------------------------------------------------------------- /app/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 6 | 7 | 17 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 34 | 35 | 36 | 37 | 40 | 41 | 42 | 43 | -------------------------------------------------------------------------------- /app/src/main/java/com/kormax/observemodedemo/MainActivity.kt: -------------------------------------------------------------------------------- 1 | package com.kormax.observemodedemo 2 | 3 | import android.content.ComponentName 4 | import android.content.pm.ActivityInfo 5 | import android.nfc.NfcAdapter 6 | import android.nfc.NfcAdapter.FLAG_LISTEN_KEEP 7 | import android.nfc.NfcAdapter.FLAG_READER_DISABLE 8 | import android.nfc.cardemulation.CardEmulation 9 | import android.os.Bundle 10 | import android.util.Log 11 | import androidx.activity.ComponentActivity 12 | import androidx.activity.compose.setContent 13 | import androidx.compose.foundation.background 14 | import androidx.compose.foundation.border 15 | import androidx.compose.foundation.layout.Arrangement 16 | import androidx.compose.foundation.layout.Box 17 | import androidx.compose.foundation.layout.Column 18 | import androidx.compose.foundation.layout.IntrinsicSize 19 | import androidx.compose.foundation.layout.PaddingValues 20 | import androidx.compose.foundation.layout.Row 21 | import androidx.compose.foundation.layout.fillMaxSize 22 | import androidx.compose.foundation.layout.fillMaxWidth 23 | import androidx.compose.foundation.layout.padding 24 | import androidx.compose.foundation.layout.width 25 | import androidx.compose.foundation.layout.wrapContentHeight 26 | import androidx.compose.foundation.lazy.LazyColumn 27 | import androidx.compose.foundation.shape.RoundedCornerShape 28 | import androidx.compose.material.icons.Icons 29 | import androidx.compose.material.icons.filled.Refresh 30 | import androidx.compose.material.icons.outlined.CheckCircle 31 | import androidx.compose.material.icons.outlined.Info 32 | import androidx.compose.material.icons.rounded.Warning 33 | import androidx.compose.material3.Button 34 | import androidx.compose.material3.CardDefaults 35 | import androidx.compose.material3.Divider 36 | import androidx.compose.material3.DropdownMenu 37 | import androidx.compose.material3.DropdownMenuItem 38 | import androidx.compose.material3.ElevatedCard 39 | import androidx.compose.material3.Icon 40 | import androidx.compose.material3.IconButton 41 | import androidx.compose.material3.MaterialTheme 42 | import androidx.compose.material3.Scaffold 43 | import androidx.compose.material3.SnackbarHost 44 | import androidx.compose.material3.SnackbarHostState 45 | import androidx.compose.material3.Text 46 | import androidx.compose.material3.TopAppBar 47 | import androidx.compose.material3.TopAppBarDefaults 48 | import androidx.compose.runtime.Composable 49 | import androidx.compose.runtime.getValue 50 | import androidx.compose.runtime.mutableStateOf 51 | import androidx.compose.runtime.remember 52 | import androidx.compose.runtime.rememberCoroutineScope 53 | import androidx.compose.runtime.setValue 54 | import androidx.compose.ui.Alignment 55 | import androidx.compose.ui.Modifier 56 | import androidx.compose.ui.graphics.Color 57 | import androidx.compose.ui.graphics.compositeOver 58 | import androidx.compose.ui.text.style.TextAlign 59 | import androidx.compose.ui.unit.dp 60 | import com.kormax.observemodedemo.ui.theme.ObserveModeDemoTheme 61 | import kotlinx.coroutines.launch 62 | 63 | class MainActivity : ComponentActivity() { 64 | private val TAG = this::class.java.simpleName 65 | 66 | private var errors: List = listOf() 67 | private val component = 68 | ComponentName( 69 | "com.kormax.observemodedemo", 70 | "com.kormax.observemodedemo.ObserveModeHostApduService", 71 | ) 72 | private val sortThreshold = 16 73 | private val sampleThreshold = 64 74 | private val wrapThreshold = 1_000_000L * 3 // 3 seconds 75 | 76 | override fun onCreate(savedInstanceState: Bundle?) { 77 | super.onCreate(savedInstanceState) 78 | val appContext = this 79 | 80 | setContent { 81 | EnforceScreenOrientation(ActivityInfo.SCREEN_ORIENTATION_PORTRAIT) 82 | val snackbarHostState = remember { SnackbarHostState() } 83 | val scope = rememberCoroutineScope() 84 | 85 | var errors: List by remember { mutableStateOf(this.errors) } 86 | 87 | var loopEvents: List by remember { mutableStateOf(listOf()) } 88 | 89 | var currentLoop: List by remember { mutableStateOf(listOf()) } 90 | 91 | var modeMenuExpanded by remember { mutableStateOf(false) } 92 | var currentMode: DisplayMode by remember { mutableStateOf(DisplayMode.LOOP) } 93 | 94 | SystemBroadcastReceiver(Constants.POLLING_LOOP_EVENT_ACTION) { intent -> 95 | val event = 96 | intent?.getParcelableExtra( 97 | Constants.POLLING_LOOP_EVENT_DATA_KEY 98 | ) ?: return@SystemBroadcastReceiver 99 | 100 | loopEvents += event.frames.map { PollingLoopEvent(it, -1, event.at) } 101 | 102 | val toSort = loopEvents.takeLast(maxOf(sortThreshold, event.frames.size) + 1) 103 | 104 | // HostEmulation manager may deliver "Expeditable" frame types (F, U) in front of 105 | // all of the other frames regardless of the original order. 106 | // Because of that, we have to re-sort the received loop events back after the fact. 107 | // What complicates the task, is that the timestamp value may wrap back 108 | // due to specifics of internal timer implementation on the NFC controller 109 | // To handle that, we can sample N last frames, find out which ones were not wrapped 110 | // by checking that they are not M seconds earlier than the first frame, and split 111 | // frames into wrapped and not-wrapped after the fact 112 | // Inside of those two groups, we can restore the order by sorting via timestamps. 113 | // For the resulting events, re-calculate the delta from the previous event, 114 | // and replace last N events with the updated ones 115 | if (toSort.isNotEmpty()) { 116 | val first = toSort.first() 117 | 118 | val (wrapped, notWrapped) = 119 | toSort.drop(1).partition { it.timestamp < first.timestamp - wrapThreshold } 120 | 121 | if (wrapped.isNotEmpty()) { 122 | Log.i( 123 | TAG, 124 | "Loop wrapped" + 125 | " from ${notWrapped.size} ${ 126 | mapPollingLoopEventsToString( 127 | notWrapped 128 | ) 129 | }" + 130 | " to ${wrapped.size} ${mapPollingLoopEventsToString(wrapped)}", 131 | ) 132 | } 133 | 134 | val sorted = 135 | (notWrapped.sortedBy { it.timestamp } + wrapped.sortedBy { it.timestamp }) 136 | 137 | // Update deltas for sorted elements 138 | var previousTimestamp = first.timestamp 139 | val updated = 140 | sorted 141 | .mapIndexed { index, element -> 142 | val delta = (element.timestamp - previousTimestamp).coerceAtLeast(0) 143 | previousTimestamp = element.timestamp 144 | element.withDelta(delta) 145 | } 146 | .toMutableList() 147 | 148 | loopEvents = loopEvents.dropLast(updated.size) + updated 149 | } 150 | 151 | errors = emptyList() 152 | 153 | // Sample 64 last frames. 154 | val sample = loopEvents.takeLast(sampleThreshold).toTypedArray() 155 | 156 | // Find a sequence that repeats at least two times back-to-back 157 | val unalignedLoop = 158 | largestRepeatingSequence( 159 | sample, 160 | { it1, it2 -> it1.type == it2.type && it1.data.contentEquals(it2.data) }, 161 | ) 162 | // Attempt to align polling loop sequence based on general assumptions about polling 163 | // loops 164 | val loop = alignPollingLoop(unalignedLoop) 165 | 166 | if (loop.isNotEmpty()) { 167 | currentLoop = mapPollingEventsToLoopActivity(loop) 168 | } 169 | } 170 | 171 | ObserveModeDemoTheme { 172 | Scaffold( 173 | snackbarHost = { SnackbarHost(hostState = snackbarHostState) }, 174 | topBar = { 175 | TopAppBar( 176 | colors = 177 | TopAppBarDefaults.topAppBarColors( 178 | containerColor = MaterialTheme.colorScheme.primaryContainer, 179 | titleContentColor = MaterialTheme.colorScheme.primary, 180 | ), 181 | title = { Text("NFC Observer") }, 182 | actions = { 183 | Box { 184 | IconButton(onClick = { modeMenuExpanded = true }) { 185 | Icon( 186 | imageVector = 187 | if (modeMenuExpanded) Icons.Outlined.CheckCircle 188 | else Icons.Outlined.Info, 189 | contentDescription = "Toggle detailed info", 190 | ) 191 | } 192 | DropdownMenu( 193 | expanded = modeMenuExpanded, 194 | onDismissRequest = { modeMenuExpanded = false }, 195 | ) { 196 | DropdownMenuItem( 197 | { Text("History") }, 198 | onClick = { 199 | currentMode = DisplayMode.HISTORY 200 | modeMenuExpanded = false 201 | }, 202 | trailingIcon = { 203 | if (currentMode == DisplayMode.HISTORY) { 204 | Icon( 205 | imageVector = Icons.Outlined.CheckCircle, 206 | contentDescription = "Activated", 207 | ) 208 | } 209 | }, 210 | ) 211 | 212 | Divider() 213 | 214 | DropdownMenuItem( 215 | { Text("Loop") }, 216 | onClick = { 217 | currentMode = DisplayMode.LOOP 218 | modeMenuExpanded = false 219 | }, 220 | trailingIcon = { 221 | if (currentMode == DisplayMode.LOOP) { 222 | Icon( 223 | imageVector = Icons.Outlined.CheckCircle, 224 | contentDescription = "Activated", 225 | ) 226 | } 227 | }, 228 | ) 229 | } 230 | } 231 | IconButton(onClick = { loopEvents = listOf() }) { 232 | Icon( 233 | imageVector = Icons.Filled.Refresh, 234 | contentDescription = "Clear polling frame history", 235 | ) 236 | } 237 | }, 238 | ) 239 | }, 240 | ) { innerPadding -> 241 | Column(modifier = Modifier.fillMaxSize().padding(innerPadding)) { 242 | Column(modifier = Modifier.weight(8f).fillMaxWidth()) { 243 | if (errors.isNotEmpty()) { 244 | Column( 245 | modifier = Modifier.fillMaxSize().padding(12.dp), 246 | verticalArrangement = Arrangement.spacedBy(8.dp), 247 | ) { 248 | for (error in errors) { 249 | Row( 250 | modifier = Modifier.padding(10.dp), 251 | verticalAlignment = Alignment.CenterVertically, 252 | horizontalArrangement = Arrangement.spacedBy(8.dp), 253 | ) { 254 | Icon( 255 | imageVector = Icons.Rounded.Warning, 256 | contentDescription = "Warning", 257 | ) 258 | Text(text = error) 259 | } 260 | } 261 | } 262 | } else { 263 | LazyColumn( 264 | modifier = Modifier.fillMaxSize(), 265 | contentPadding = PaddingValues(12.dp), 266 | verticalArrangement = Arrangement.spacedBy(8.dp), 267 | userScrollEnabled = true, 268 | ) { 269 | when (currentMode) { 270 | DisplayMode.LOOP -> 271 | items( 272 | currentLoop.size, 273 | { 274 | "${loopEvents.getOrNull(it)?.at}:${ 275 | loopEvents.getOrNull( 276 | it 277 | )?.timestamp 278 | }:${it}" 279 | }, 280 | ) { index -> 281 | val loop = 282 | currentLoop.getOrNull(index) ?: return@items 283 | PollingLoopItem(loop = loop) 284 | } 285 | 286 | DisplayMode.HISTORY -> 287 | items( 288 | loopEvents.size, 289 | { 290 | "${loopEvents.getOrNull(it)?.at}:${ 291 | loopEvents.getOrNull( 292 | it 293 | )?.timestamp 294 | }:${it}" 295 | }, 296 | ) { index -> 297 | val event = 298 | loopEvents.getOrNull(index) ?: return@items 299 | PollingEventItem( 300 | event = event, 301 | display = "timestamp", 302 | ) 303 | } 304 | } 305 | } 306 | } 307 | } 308 | Divider() 309 | Column( 310 | modifier = 311 | Modifier.weight(1f) 312 | .fillMaxWidth() 313 | .background(color = Color.Transparent), 314 | verticalArrangement = Arrangement.Center, 315 | horizontalAlignment = Alignment.CenterHorizontally, 316 | ) { 317 | Button( 318 | onClick = { 319 | scope.launch { 320 | val nfcAdapter: NfcAdapter? = 321 | try { 322 | NfcAdapter.getDefaultAdapter(appContext) 323 | } catch (_: Exception) { 324 | null 325 | } 326 | 327 | if (nfcAdapter == null) { 328 | return@launch 329 | } 330 | 331 | val enabled = nfcAdapter.isObserveModeEnabled 332 | 333 | val success = nfcAdapter.setObserveModeEnabled(!enabled) 334 | 335 | snackbarHostState.showSnackbar( 336 | "Observe mode " + 337 | (if (!enabled) "enabled" else "disabled") + 338 | " " + 339 | (if (success) "successfully" else "unsuccessfully") 340 | ) 341 | } 342 | } 343 | ) { 344 | Text("Toggle Observe Mode") 345 | } 346 | } 347 | } 348 | } 349 | } 350 | } 351 | } 352 | 353 | override fun onResume() { 354 | super.onResume() 355 | 356 | errors = mutableListOf() 357 | 358 | val nfcAdapter: NfcAdapter? = 359 | try { 360 | NfcAdapter.getDefaultAdapter(this) 361 | } catch (_: Exception) { 362 | null 363 | } 364 | 365 | if (nfcAdapter == null) { 366 | errors += "Unable to get NfcAdapter" 367 | return 368 | } else if (!nfcAdapter.isEnabled) { 369 | errors += "NFC is disabled" 370 | return 371 | } 372 | 373 | val cardEmulation = 374 | try { 375 | CardEmulation.getInstance(nfcAdapter) 376 | } catch (_: Exception) { 377 | Log.e(TAG, "CardEmulation is unavailable") 378 | null 379 | } 380 | 381 | if (cardEmulation == null) { 382 | errors += "Unable to get CardEmulation" 383 | return 384 | } 385 | 386 | try { 387 | val observeModeEnabled = nfcAdapter.isObserveModeEnabled 388 | if (!observeModeEnabled && !nfcAdapter.setObserveModeEnabled(true)) { 389 | errors += "Unable to enable Observe Mode" 390 | } 391 | if (!cardEmulation.removeAidsForService(component, "payment")) { 392 | errors += "Unable to remove AID for service" 393 | } 394 | 395 | if ( 396 | !cardEmulation.registerAidsForService( 397 | component, 398 | "payment", 399 | listOf("A0C0FFEEC0FFEE"), 400 | ) 401 | ) { 402 | errors += "Unable to register AID for service" 403 | } 404 | 405 | if (!cardEmulation.setPreferredService(this, component)) { 406 | errors += "Unable to set preferred service" 407 | } 408 | 409 | try { 410 | nfcAdapter.setDiscoveryTechnology(this, FLAG_READER_DISABLE, FLAG_LISTEN_KEEP) 411 | } catch (_: Exception) { 412 | errors += "Unable to set discovery technology" 413 | } 414 | } catch (e: Exception) { 415 | errors += "${e}" 416 | } 417 | } 418 | } 419 | 420 | @Composable 421 | fun PollingLoopItem(loop: Loop) { 422 | return Column( 423 | verticalArrangement = Arrangement.spacedBy(8.dp), 424 | horizontalAlignment = Alignment.CenterHorizontally, 425 | ) { 426 | Text( 427 | text = mapDeltaToTimeText(loop.startDelta) 428 | // fontSize = 12.sp 429 | ) 430 | 431 | ElevatedCard( 432 | colors = 433 | CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant), 434 | elevation = CardDefaults.cardElevation(defaultElevation = 1.dp), 435 | shape = RoundedCornerShape(18.dp), 436 | modifier = Modifier.fillMaxWidth().wrapContentHeight(), 437 | ) { 438 | Column( 439 | modifier = Modifier.fillMaxWidth().padding(8.dp), 440 | verticalArrangement = Arrangement.spacedBy(8.dp), 441 | horizontalAlignment = Alignment.CenterHorizontally, 442 | ) { 443 | for (event in loop.events) { 444 | // Decide if we want to display delta values between frame blocks or in them 445 | /*Text( 446 | text = mapTimestampToTimeText(event.delta), 447 | fontSize = 12.sp 448 | )*/ 449 | PollingEventItem(event = event, display = "delta") 450 | } 451 | Text( 452 | text = mapDeltaToTimeText(loop.endDelta) 453 | // fontSize = 12.sp 454 | ) 455 | } 456 | } 457 | } 458 | } 459 | 460 | @Composable 461 | fun PollingEventItem(event: PollingLoopEvent, display: String = "delta") { 462 | val (typeName, color) = mapPollingFrameTypeToNameAndColor(event.type) 463 | val dataGainDisplayed = typeName !in Constants.POLLING_FRAME_TYPES_WITHOUT_GAIN_AND_DATA 464 | 465 | val (type, delta, gain) = 466 | Triple( 467 | typeName, 468 | when (display) { 469 | "delta" -> mapDeltaToTimeText(event.delta) 470 | "timestamp" -> event.timestamp.toString() 471 | else -> "" 472 | }, 473 | mapVendorSpecificGainToPowerPercentage(event.vendorSpecificGain), 474 | ) 475 | 476 | ElevatedCard( 477 | colors = 478 | CardDefaults.cardColors( 479 | containerColor = 480 | MaterialTheme.colorScheme.surfaceVariant 481 | .copy(alpha = 0.8f) 482 | .compositeOver(color.copy(alpha = 0.2f)) 483 | ), 484 | elevation = CardDefaults.cardElevation(defaultElevation = 8.dp), 485 | modifier = 486 | Modifier.fillMaxWidth() 487 | .wrapContentHeight() 488 | .border( 489 | width = 1.dp, 490 | color = color.copy(alpha = 0.8f), 491 | shape = MaterialTheme.shapes.medium, 492 | ), 493 | ) { 494 | Column(modifier = Modifier.fillMaxWidth().width(IntrinsicSize.Max)) { 495 | Row( 496 | modifier = 497 | Modifier.padding( 498 | bottom = if (dataGainDisplayed) 4.dp else 10.dp, 499 | top = 10.dp, 500 | start = 10.dp, 501 | end = 10.dp, 502 | ) 503 | .fillMaxWidth() 504 | ) { 505 | Text(text = type, modifier = Modifier.width(32.dp)) 506 | 507 | Text(text = event.name) 508 | if (display != "") { 509 | Text( 510 | textAlign = TextAlign.End, 511 | text = delta, 512 | modifier = Modifier.fillMaxWidth(), 513 | ) 514 | } 515 | } 516 | if (dataGainDisplayed) { 517 | Divider(modifier = Modifier.fillMaxWidth(), color = color.copy(alpha = 0.6f)) 518 | Row( 519 | modifier = 520 | Modifier.padding(top = 4.dp, bottom = 10.dp, start = 10.dp, end = 10.dp), 521 | horizontalArrangement = Arrangement.SpaceBetween, 522 | ) { 523 | Text(modifier = Modifier.weight(1f), text = event.data.toHexString()) 524 | Text(text = gain) 525 | } 526 | } 527 | } 528 | } 529 | } 530 | -------------------------------------------------------------------------------- /app/src/main/java/com/kormax/observemodedemo/ObserveModeHostApduService.kt: -------------------------------------------------------------------------------- 1 | package com.kormax.observemodedemo 2 | 3 | import android.content.Intent 4 | import android.nfc.cardemulation.HostApduService 5 | import android.nfc.cardemulation.PollingFrame 6 | import android.os.Bundle 7 | import android.os.SystemClock 8 | import android.util.Log 9 | 10 | class ObserveModeHostApduService : HostApduService() { 11 | private val TAG = this::class.java.simpleName 12 | 13 | override fun processCommandApdu(commandApdu: ByteArray, extras: Bundle?): ByteArray { 14 | Log.i(TAG, "processCommandApdu(${commandApdu.toHexString()}, ${extras})") 15 | return ByteArray(0) 16 | } 17 | 18 | override fun processPollingFrames(frames: List) { 19 | Log.i(TAG, "processPollingFrames received ${frames.size} frames") 20 | sendBroadcast( 21 | Intent(Constants.POLLING_LOOP_EVENT_ACTION).apply { 22 | putExtra( 23 | Constants.POLLING_LOOP_EVENT_DATA_KEY, 24 | PollingFrameNotification( 25 | frames.toTypedArray(), 26 | SystemClock.elapsedRealtimeNanos(), 27 | ), 28 | ) 29 | } 30 | ) 31 | } 32 | 33 | override fun onDeactivated(reason: Int) { 34 | Log.i(TAG, "onDeactivated(${reason})") 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /app/src/main/java/com/kormax/observemodedemo/Utilities.kt: -------------------------------------------------------------------------------- 1 | package com.kormax.observemodedemo 2 | 3 | import android.app.Activity 4 | import android.content.BroadcastReceiver 5 | import android.content.Context 6 | import android.content.ContextWrapper 7 | import android.content.Intent 8 | import android.content.IntentFilter 9 | import android.nfc.cardemulation.PollingFrame 10 | import android.os.Parcelable 11 | import android.os.SystemClock 12 | import androidx.compose.runtime.Composable 13 | import androidx.compose.runtime.DisposableEffect 14 | import androidx.compose.runtime.getValue 15 | import androidx.compose.runtime.rememberUpdatedState 16 | import androidx.compose.ui.graphics.Color 17 | import androidx.compose.ui.platform.LocalContext 18 | import java.time.Instant 19 | import java.time.Instant.now 20 | import kotlin.experimental.and 21 | import kotlin.math.ceil 22 | import kotlinx.parcelize.Parcelize 23 | 24 | class Constants { 25 | companion object { 26 | const val POLLING_LOOP_EVENT_ACTION = "com.kormax.observemodedemo.POLLING_FRAME_DATA" 27 | const val POLLING_LOOP_EVENT_DATA_KEY = "frame" 28 | 29 | val POLLING_FRAME_TYPES_WITHOUT_GAIN_AND_DATA = setOf("X", "O") 30 | } 31 | } 32 | 33 | enum class DisplayMode { 34 | HISTORY, 35 | LOOP, 36 | } 37 | 38 | data class Loop( 39 | val startDelta: Long, 40 | val endDelta: Long, 41 | val events: Array, 42 | val timestamp: Instant = now(), 43 | ) 44 | 45 | @Parcelize 46 | class PollingFrameNotification(val frames: Array, val at: Long) : Parcelable 47 | 48 | @Parcelize 49 | class PollingLoopEvent( 50 | val timestamp: Long, 51 | val type: Int, 52 | val data: ByteArray, 53 | val vendorSpecificGain: Int, 54 | var delta: Long = -1, 55 | val at: Long = SystemClock.elapsedRealtimeNanos(), 56 | ) : Parcelable { 57 | 58 | constructor( 59 | frame: PollingFrame 60 | ) : this( 61 | frame.timestamp, 62 | frame.type, 63 | frame.data, 64 | frame.vendorSpecificGain, 65 | -1, 66 | SystemClock.elapsedRealtimeNanos(), 67 | ) 68 | 69 | constructor( 70 | frame: PollingFrame, 71 | delta: Long, 72 | at: Long = SystemClock.elapsedRealtimeNanos(), 73 | ) : this( 74 | frame.timestamp, 75 | frame.type, 76 | frame.data, 77 | frame.vendorSpecificGain, 78 | frame.timestamp - delta, 79 | at, 80 | ) 81 | 82 | fun withDelta(delta: Long): PollingLoopEvent { 83 | return PollingLoopEvent(timestamp, type, data, vendorSpecificGain, delta, at) 84 | } 85 | 86 | companion object { 87 | val A = PollingFrame.POLLING_LOOP_TYPE_A 88 | val B = PollingFrame.POLLING_LOOP_TYPE_B 89 | val F = PollingFrame.POLLING_LOOP_TYPE_F 90 | val ON = PollingFrame.POLLING_LOOP_TYPE_ON 91 | val OFF = PollingFrame.POLLING_LOOP_TYPE_OFF 92 | val UNKNOWN = PollingFrame.POLLING_LOOP_TYPE_UNKNOWN 93 | } 94 | 95 | val name: String by lazy { 96 | val hex = data.toHexString().lowercase() 97 | return@lazy when (type) { 98 | A -> parseTypeAFrame(hex) 99 | B -> parseTypeBFrame(hex) 100 | F -> parseTypeFFrame(hex) 101 | ON, 102 | OFF -> "" 103 | else -> parseOtherFrameTypes(hex) 104 | } 105 | } 106 | } 107 | 108 | fun parseOtherFrameTypes(data: String): String { 109 | // French stuff based on Type B with valid CRC 110 | if (data == "010b3f80") { 111 | // Innovatron, B' prime 112 | return "APGEN" 113 | } 114 | if (data == "0600") { 115 | // ST SRX 116 | return "INITIATE" 117 | } 118 | if (data == "10") { 119 | // ASK CTX REQT 120 | return "REQT" 121 | } 122 | 123 | // SLP_REQ shouldn't be sent at discovery if nothing was found, but some readers do nonetheless 124 | if (data == "5000") { 125 | // ISO14443-3A HLTA 126 | return "HLTA" 127 | } 128 | if (data.startsWith("50") && data.length == 10) { 129 | // ISO14443-3B HLTB 130 | return "HLTB" 131 | } 132 | 133 | // Picopass, based on Type B (legacy) and V. Does not use proper CRC 134 | if (data.length in 2..4) { 135 | if (data.endsWith("0a")) { 136 | // Picopass wakeup 137 | return "ACTALL" 138 | } 139 | // Weird case 140 | if (data.endsWith("0c")) { 141 | // Picopass anticollision command 142 | return "IDENTIFY" 143 | } 144 | } 145 | 146 | // ECP, based on Type A B F 147 | if (data.startsWith("6a") && data.length >= 8) { 148 | return "ECP" + 149 | when (data.substring(2, 4)) { 150 | "01" -> "1_" + parseECP1(data) 151 | "02" -> "2_" + parseECP2(data) 152 | else -> "_UNKNOWN" 153 | } 154 | } 155 | 156 | // MagSafe, based on Type A 157 | if (data.startsWith("7") && data.length == 2) { 158 | return "MAGWUP" + 159 | when (data) { 160 | "7a" -> "1" 161 | "7b" -> "2" 162 | "7c" -> "3" 163 | "7d" -> "4" 164 | else -> "U" 165 | } 166 | } 167 | 168 | // Mifare magic card wakeup, based on Type A 169 | if (data.length == 2) { 170 | when (data) { 171 | "40", 172 | "20" -> return "WUPC1" 173 | "43", 174 | "23" -> return "WUPC2" 175 | } 176 | } 177 | 178 | return "PLF" 179 | } 180 | 181 | fun parseTypeAFrame(data: String): String { 182 | if (data.startsWith("52")) { 183 | return "WUPA" 184 | } else if (data.startsWith("26")) { 185 | return "REQA" 186 | } 187 | return parseOtherFrameTypes(data) 188 | } 189 | 190 | fun parseTypeBFrame(data: String): String { 191 | if (data.startsWith("05")) { 192 | if ((data.substring(data.length - 2, data.length).hexToByte() and 0x08.toByte()) > 0) { 193 | return "WUPB" 194 | } else { 195 | return "REQB" 196 | } 197 | } 198 | return parseOtherFrameTypes(data) 199 | } 200 | 201 | fun parseTypeFFrame(data: String): String { 202 | if (data.length >= 6) { 203 | return parseFeliCaSystemCode(data.substring(2, 6)) 204 | } 205 | return parseOtherFrameTypes(data) 206 | } 207 | 208 | fun parseFeliCaSystemCode(systemCode: String) = 209 | when (systemCode.lowercase()) { 210 | "ffff" -> "WILDCARD" 211 | "0003" -> "CJRC" 212 | "8008" -> "OCTOPUS" 213 | "fe00" -> "COMMON" 214 | "12fc" -> "NDEF" 215 | "88b4" -> "LITE" 216 | "957a" -> "ID" 217 | else -> "UNKNOWN" 218 | } 219 | 220 | fun parseECPTransitTCI(tci: String): String = 221 | when (tci.lowercase()) { 222 | "030000" -> "VENTRA" 223 | "030400" -> "HOPCARD" 224 | "030002" -> "TFL" 225 | "030001" -> "WMATA" 226 | "030005" -> "LATAP" 227 | "030007" -> "CLIPPER" 228 | "03095a" -> "NAVIGO" 229 | else -> "UNKNOWN" 230 | } 231 | 232 | fun parseECPAccessSubtype(value: String) = 233 | when (value.lowercase()) { 234 | "00" -> "UNIVERSITY" 235 | "01" -> "AUTOMOTIVE" 236 | "08" -> "AUTOMOTIVE" 237 | "09" -> "AUTOMOTIVE" 238 | "0a" -> "AUTOMOTIVE" 239 | "0b" -> "AUTOMOTIVE" 240 | "06" -> "HOME" 241 | else -> "UNKNOWN" 242 | } 243 | 244 | fun parseECP2(value: String): String { 245 | if (value.length < 8) { 246 | return "UNKNOWN" 247 | } 248 | return when (value.substring(6, 8)) { 249 | "01" -> "TRANSIT_" + parseECPTransitTCI(value.substring(10, 16)) 250 | "02" -> "ACCESS_" + parseECPAccessSubtype(value.substring(8, 10)) 251 | "03" -> "IDENTITY" 252 | "05" -> "HANDOVER" 253 | else -> "UNKNOWN" 254 | } 255 | } 256 | 257 | fun parseECP1(value: String): String { 258 | return when (value) { 259 | "6a01000000" -> "VAS_OR_PAYMENT" 260 | "6a01000001" -> "VAS_AND_PAYMENT" 261 | "6a01000002" -> "VAS_ONLY" 262 | "6a01000003" -> "PAY_ONLY" 263 | "6a01cf0000" -> "IGNORE" 264 | "6a01c30000" -> "GYMKIT" 265 | else -> { 266 | if (value.startsWith("6a0103")) { 267 | return "TRANSIT_" + parseECPTransitTCI(value.substring(4)) 268 | } 269 | return "UNKNOWN" 270 | } 271 | } 272 | } 273 | 274 | @Composable 275 | fun SystemBroadcastReceiver(systemAction: String, onSystemEvent: (intent: Intent?) -> Unit) { 276 | // Grab the current context in this part of the UI tree 277 | val context = LocalContext.current 278 | 279 | // Safely use the latest onSystemEvent lambda passed to the function 280 | val currentOnSystemEvent by rememberUpdatedState(onSystemEvent) 281 | 282 | // If either context or systemAction changes, unregister and register again 283 | DisposableEffect(context, systemAction) { 284 | val intentFilter = IntentFilter(systemAction) 285 | val broadcast = 286 | object : BroadcastReceiver() { 287 | override fun onReceive(context: Context?, intent: Intent?) { 288 | currentOnSystemEvent(intent) 289 | } 290 | } 291 | 292 | context.registerReceiver(broadcast, intentFilter, Context.RECEIVER_EXPORTED) 293 | // When the effect leaves the Composition, remove the callback 294 | onDispose { context.unregisterReceiver(broadcast) } 295 | } 296 | } 297 | 298 | @Composable 299 | fun EnforceScreenOrientation(orientation: Int) { 300 | val context = LocalContext.current 301 | DisposableEffect(Unit) { 302 | val activity = context.findActivity() ?: return@DisposableEffect onDispose {} 303 | val originalOrientation = activity.requestedOrientation 304 | activity.requestedOrientation = orientation 305 | onDispose { activity.requestedOrientation = originalOrientation } 306 | } 307 | } 308 | 309 | fun Context.findActivity(): Activity? = 310 | when (this) { 311 | is Activity -> this 312 | is ContextWrapper -> baseContext.findActivity() 313 | else -> null 314 | } 315 | 316 | fun mapDeltaToTimeText(microseconds: Long) = 317 | when { 318 | microseconds == -1L -> "Continuous" 319 | microseconds >= 60_000_000 -> { 320 | val minutes = ceil(microseconds / 60_000_000.0).toInt() 321 | "$minutes min" 322 | } 323 | 324 | microseconds >= 1_000_000 -> { 325 | val seconds = ceil(microseconds / 1_000_000.0).toInt() 326 | "$seconds s" 327 | } 328 | 329 | microseconds >= 1_000 -> { 330 | val milliseconds = ceil(microseconds / 1_000.0).toInt() 331 | "$milliseconds ms" 332 | } 333 | 334 | else -> { 335 | "$microseconds us" 336 | } 337 | } 338 | 339 | fun mapVendorSpecificGainToPowerPercentage(vendorSpecificGain: Int): String { 340 | // Currently only Google Pixel with the ST chip have observe mode available, so this works fine 341 | // In the future, this would require maintaining a database of value ranges for each model 342 | // or an ability to get the adjusted value, based on OEM calibration like done for BLE. 343 | val squashedGain = vendorSpecificGain.coerceIn(0, 12) 344 | val percentage = squashedGain * 100 / 12 345 | return "$percentage%" 346 | } 347 | 348 | fun mapPollingFrameTypeToNameAndColor(type: Int) = 349 | when (type) { 350 | PollingLoopEvent.A -> ("A" to Color(0xFF0E79B2)) 351 | PollingLoopEvent.B -> ("B" to Color(0xFFF53E54)) 352 | PollingLoopEvent.F -> ("F" to Color(0xFF7AC74F)) 353 | PollingLoopEvent.OFF -> ("X" to Color.DarkGray) 354 | PollingLoopEvent.ON -> ("O" to Color.White) 355 | PollingLoopEvent.UNKNOWN -> ("U" to Color.Magenta) 356 | else -> "U(${type})" to Color.Magenta 357 | } 358 | 359 | fun mapPollingFrameTypeToName(type: Int) = mapPollingFrameTypeToNameAndColor(type).first 360 | 361 | fun mapPollingLoopToString(frames: Array) = 362 | frames.map { mapPollingFrameTypeToName(it.type) }.joinToString("") { it } 363 | 364 | fun mapPollingLoopEventToString(event: PollingLoopEvent): String = 365 | "${ 366 | mapPollingFrameTypeToName(event.type) 367 | }(${event.data.toHexString()})[${event.timestamp}]" 368 | 369 | fun mapPollingLoopEventsToString(events: Collection): String = 370 | "${events.map { mapPollingLoopEventToString(it) }}" 371 | 372 | inline fun smallestRepeatingSequence( 373 | arr: Array, 374 | noinline comparator: (T, T) -> Boolean, 375 | ): Array { 376 | val n = arr.size 377 | for (length in 1..ceil((n / 2).toDouble()).toInt()) { 378 | if (n % length == 0) { 379 | val subArray = arr.copyOfRange(0, length) 380 | if (arr.equalTo(subArray.repeat(n / length), comparator)) { 381 | return subArray 382 | } 383 | } 384 | } 385 | return emptyArray() 386 | } 387 | 388 | val fieldTypeToIndex = 389 | hashMapOf( 390 | // PollingFrame.POLLING_LOOP_TYPE_ON to 1, 391 | PollingFrame.POLLING_LOOP_TYPE_A to 2, 392 | PollingFrame.POLLING_LOOP_TYPE_B to 3, 393 | PollingFrame.POLLING_LOOP_TYPE_F to 4, 394 | // PollingFrame.POLLING_LOOP_TYPE_V to 5 395 | // PollingFrame.POLLING_LOOP_TYPE_OFF to 6 396 | ) 397 | 398 | fun alignPollingLoop(events: Array): Array { 399 | // Attempt to align the polling loop by calculating best rotation using the following rules: 400 | // 401 | // 1. If there are rotations which start with an ON event and end with an OFF event, discard all 402 | // other rotations 403 | // 2. Give 2 points for each type for loops that follow the A -> B -> F order (Unknown frame 404 | // type is ignored) 405 | // 3. If there are empty ON and OFF event pairs, give 1 point if they are placed at the end 406 | // 407 | // As a potential improvement, delta values could also be taken into account to find proper 408 | // alignment 409 | 410 | if (events.size <= 1) { 411 | return events 412 | } 413 | 414 | // Stores a pair of score and rotation values 415 | var biggest = Pair(0.0, 0) 416 | 417 | val rotations = 418 | events.indices 419 | .filter { 420 | events.get(0, it).type == PollingLoopEvent.ON && 421 | events.get(events.size - 1, it).type == PollingLoopEvent.OFF 422 | } 423 | .ifEmpty { events.indices.toList() } 424 | 425 | for (rotation in rotations) { 426 | var score = 0.0 427 | var previousTech = -1 428 | 429 | // To provide stability in continuous loops 430 | // assign score based on length 431 | score += events.get(0, rotation).data.size * 0.000001 432 | // first byte 433 | score += events.get(0, rotation).data.getOrElse(0, { "00".hexToByte() }) * 0.000000001 434 | 435 | for (index in events.indices) { 436 | val currentTech = fieldTypeToIndex.getOrDefault(events.get(index, rotation).type, -1) 437 | if (previousTech < currentTech) { 438 | score += 2 439 | previousTech = currentTech 440 | } else if (previousTech == currentTech) { 441 | // Prefer if same events come back-to-back 442 | score += 0.001 443 | } 444 | } 445 | 446 | // If rotated polling loop does not start with an empty pair of ON and OFF events 447 | // (instead, ends with one), increase score 448 | if ( 449 | events.get(events.size - 2, rotation).type == PollingLoopEvent.ON && 450 | events.get(events.size - 1, rotation).type == PollingLoopEvent.OFF 451 | ) { 452 | score += 1 453 | } 454 | 455 | if (biggest.first < score) { 456 | biggest = Pair(score, rotation) 457 | } 458 | } 459 | 460 | return events.rotate(biggest.second) 461 | } 462 | 463 | inline fun largestRepeatingSequence( 464 | arr: Array, 465 | noinline comparator: (T, T) -> Boolean, 466 | ): Array { 467 | val possibilities = mutableListOf>() 468 | for (start in 0 until Math.ceil((arr.size / 2).toDouble()).toInt()) { 469 | for (end in start until arr.size) { 470 | val pattern = arr.copyOfRange(start, end) 471 | if (arr.containsSubArray(pattern.repeat(2), comparator)) { 472 | possibilities.add(pattern) 473 | } 474 | } 475 | } 476 | val sequence = possibilities.maxByOrNull { it.size } ?: emptyArray() 477 | return smallestRepeatingSequence(sequence, comparator).takeIf { it.isNotEmpty() } ?: sequence 478 | } 479 | 480 | inline fun Array.repeat(n: Int): Array { 481 | return Array(size * n) { this[it % size] } 482 | } 483 | 484 | inline fun Array.rotate(n: Int): Array { 485 | if (n == 0) { 486 | return this 487 | } 488 | return Array(size) { this[(it + n) % size] } 489 | } 490 | 491 | fun Array.get(index: Int, rotation: Int): T { 492 | return this[(index + rotation) % this.size] 493 | } 494 | 495 | fun Array.containsSubArray(subArray: Array, comparator: (T, T) -> Boolean): Boolean { 496 | if (subArray.isEmpty()) return true 497 | 498 | var subIndex = 0 499 | for (index in indices) { 500 | if (comparator(this[index], subArray[subIndex])) { 501 | subIndex++ 502 | if (subIndex == subArray.size) { 503 | return true 504 | } 505 | } else { 506 | subIndex = 0 507 | } 508 | } 509 | return false 510 | } 511 | 512 | fun Array.equalTo(other: Array, comparator: (T, T) -> Boolean): Boolean { 513 | if (this.size != other.size) return false 514 | 515 | for (i in this.indices) { 516 | if (!comparator(this[i], other[i])) { 517 | return false 518 | } 519 | } 520 | return true 521 | } 522 | 523 | fun mapPollingEventsToLoopActivity(events: Array): List { 524 | val result = mutableListOf() 525 | 526 | var startDelta = -1L 527 | var elements = emptyArray() 528 | var currentIndex = 0 529 | for (event in events) { 530 | if (event.type == PollingFrame.POLLING_LOOP_TYPE_OFF) { 531 | currentIndex = 0 532 | result += Loop(startDelta, event.delta, elements.map { it }.toTypedArray(), now()) 533 | elements = emptyArray() 534 | startDelta = -1 535 | continue 536 | } 537 | if (currentIndex == 0 && event.type == PollingFrame.POLLING_LOOP_TYPE_ON) { 538 | startDelta = event.delta 539 | } else { 540 | elements += event 541 | } 542 | currentIndex += 1 543 | } 544 | 545 | if (elements.isNotEmpty() || startDelta != -1L) { 546 | result += Loop(startDelta, -1, elements.map { it }.toTypedArray(), now()) 547 | } 548 | return result 549 | } 550 | -------------------------------------------------------------------------------- /app/src/main/java/com/kormax/observemodedemo/ui/theme/Color.kt: -------------------------------------------------------------------------------- 1 | package com.kormax.observemodedemo.ui.theme 2 | 3 | import androidx.compose.ui.graphics.Color 4 | 5 | val Purple80 = Color(0xFFD0BCFF) 6 | val PurpleGrey80 = Color(0xFFCCC2DC) 7 | val Pink80 = Color(0xFFEFB8C8) 8 | 9 | val Purple40 = Color(0xFF6650a4) 10 | val PurpleGrey40 = Color(0xFF625b71) 11 | val Pink40 = Color(0xFF7D5260) -------------------------------------------------------------------------------- /app/src/main/java/com/kormax/observemodedemo/ui/theme/Theme.kt: -------------------------------------------------------------------------------- 1 | package com.kormax.observemodedemo.ui.theme 2 | 3 | import android.app.Activity 4 | import android.os.Build 5 | import androidx.compose.foundation.isSystemInDarkTheme 6 | import androidx.compose.material3.MaterialTheme 7 | import androidx.compose.material3.darkColorScheme 8 | import androidx.compose.material3.dynamicDarkColorScheme 9 | import androidx.compose.material3.dynamicLightColorScheme 10 | import androidx.compose.material3.lightColorScheme 11 | import androidx.compose.runtime.Composable 12 | import androidx.compose.ui.platform.LocalContext 13 | 14 | private val DarkColorScheme = darkColorScheme( 15 | primary = Purple80, 16 | secondary = PurpleGrey80, 17 | tertiary = Pink80 18 | ) 19 | 20 | private val LightColorScheme = lightColorScheme( 21 | primary = Purple40, 22 | secondary = PurpleGrey40, 23 | tertiary = Pink40 24 | 25 | /* Other default colors to override 26 | background = Color(0xFFFFFBFE), 27 | surface = Color(0xFFFFFBFE), 28 | onPrimary = Color.White, 29 | onSecondary = Color.White, 30 | onTertiary = Color.White, 31 | onBackground = Color(0xFF1C1B1F), 32 | onSurface = Color(0xFF1C1B1F), 33 | */ 34 | ) 35 | 36 | @Composable 37 | fun ObserveModeDemoTheme( 38 | darkTheme: Boolean = isSystemInDarkTheme(), 39 | // Dynamic color is available on Android 12+ 40 | dynamicColor: Boolean = true, 41 | content: @Composable () -> Unit 42 | ) { 43 | val colorScheme = when { 44 | dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> { 45 | val context = LocalContext.current 46 | if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) 47 | } 48 | 49 | darkTheme -> DarkColorScheme 50 | else -> LightColorScheme 51 | } 52 | 53 | MaterialTheme( 54 | colorScheme = colorScheme, 55 | typography = Typography, 56 | content = content 57 | ) 58 | } -------------------------------------------------------------------------------- /app/src/main/java/com/kormax/observemodedemo/ui/theme/Type.kt: -------------------------------------------------------------------------------- 1 | package com.kormax.observemodedemo.ui.theme 2 | 3 | import androidx.compose.material3.Typography 4 | import androidx.compose.ui.text.TextStyle 5 | import androidx.compose.ui.text.font.FontFamily 6 | import androidx.compose.ui.text.font.FontWeight 7 | import androidx.compose.ui.unit.sp 8 | 9 | // Set of Material typography styles to start with 10 | val Typography = Typography( 11 | bodyLarge = TextStyle( 12 | fontFamily = FontFamily.Default, 13 | fontWeight = FontWeight.Normal, 14 | fontSize = 16.sp, 15 | lineHeight = 24.sp, 16 | letterSpacing = 0.5.sp 17 | ) 18 | /* Other default text styles to override 19 | titleLarge = TextStyle( 20 | fontFamily = FontFamily.Default, 21 | fontWeight = FontWeight.Normal, 22 | fontSize = 22.sp, 23 | lineHeight = 28.sp, 24 | letterSpacing = 0.sp 25 | ), 26 | labelSmall = TextStyle( 27 | fontFamily = FontFamily.Default, 28 | fontWeight = FontWeight.Medium, 29 | fontSize = 11.sp, 30 | lineHeight = 16.sp, 31 | letterSpacing = 0.5.sp 32 | ) 33 | */ 34 | ) -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_launcher_background.xml: -------------------------------------------------------------------------------- 1 | 2 | 7 | 10 | 15 | 20 | 25 | 30 | 35 | 40 | 45 | 50 | 55 | 60 | 65 | 70 | 75 | 80 | 85 | 90 | 95 | 100 | 105 | 110 | 115 | 120 | 125 | 130 | 135 | 140 | 145 | 150 | 155 | 160 | 165 | 170 | 171 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_launcher_foreground.xml: -------------------------------------------------------------------------------- 1 | 7 | 8 | 9 | 15 | 18 | 21 | 22 | 23 | 24 | 30 | -------------------------------------------------------------------------------- /app/src/main/res/mipmap-anydpi/ic_launcher.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /app/src/main/res/mipmap-anydpi/ic_launcher_round.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /app/src/main/res/mipmap-hdpi/ic_launcher.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kormax/android-observe-mode-demo/a47c8a0eab6e76f6e5b762a7f7421ad1bc6bd5a9/app/src/main/res/mipmap-hdpi/ic_launcher.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-hdpi/ic_launcher_round.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kormax/android-observe-mode-demo/a47c8a0eab6e76f6e5b762a7f7421ad1bc6bd5a9/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-mdpi/ic_launcher.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kormax/android-observe-mode-demo/a47c8a0eab6e76f6e5b762a7f7421ad1bc6bd5a9/app/src/main/res/mipmap-mdpi/ic_launcher.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-mdpi/ic_launcher_round.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kormax/android-observe-mode-demo/a47c8a0eab6e76f6e5b762a7f7421ad1bc6bd5a9/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xhdpi/ic_launcher.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kormax/android-observe-mode-demo/a47c8a0eab6e76f6e5b762a7f7421ad1bc6bd5a9/app/src/main/res/mipmap-xhdpi/ic_launcher.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kormax/android-observe-mode-demo/a47c8a0eab6e76f6e5b762a7f7421ad1bc6bd5a9/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxhdpi/ic_launcher.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kormax/android-observe-mode-demo/a47c8a0eab6e76f6e5b762a7f7421ad1bc6bd5a9/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kormax/android-observe-mode-demo/a47c8a0eab6e76f6e5b762a7f7421ad1bc6bd5a9/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kormax/android-observe-mode-demo/a47c8a0eab6e76f6e5b762a7f7421ad1bc6bd5a9/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kormax/android-observe-mode-demo/a47c8a0eab6e76f6e5b762a7f7421ad1bc6bd5a9/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp -------------------------------------------------------------------------------- /app/src/main/res/values/colors.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | #FFBB86FC 4 | #FF6200EE 5 | #FF3700B3 6 | #FF03DAC5 7 | #FF018786 8 | #FF000000 9 | #FFFFFFFF 10 | -------------------------------------------------------------------------------- /app/src/main/res/values/strings.xml: -------------------------------------------------------------------------------- 1 | 2 | Observe Mode Demo 3 | AID description 4 | Service description 5 | -------------------------------------------------------------------------------- /app/src/main/res/values/themes.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 |