diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..696785d --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,831 @@ +# CLAUDE.md - AI Assistant Guide + +This document provides comprehensive guidance for AI assistants working with the Tedee Lock BLE Android example application. + +## Project Overview + +**Project Name:** Tedee Demo (Tedee Lock Communication Example) +**Language:** Kotlin 2.1.0 +**Platform:** Android +**Min SDK:** 26 (Android 8.0+) +**Target SDK:** 35 (Android 15) +**Compile SDK:** 35 +**App Version:** 2.0 (versionCode: 2) +**Build System:** Gradle (AGP 8.7.3) +**Java Version:** 17 +**Primary Purpose:** Demonstration app showing Bluetooth Low Energy (BLE) communication with Tedee smart locks using the Tedee Lock SDK + +### Important Context +- This is a **simplified example** - it omits production-ready error handling and security practices +- Designed for **single lock connection** at a time (SDK limitation) +- Requires **physical Android device** (BLE not supported in emulators) +- Uses **local BLE communication only** (no Tedee cloud services except for initial certificate generation) + +## Codebase Structure + +``` +tedee-example-ble-android/ +├── app/src/main/java/tedee/mobile/demo/ +│ ├── ExampleApplication.kt # App initialization (Timber logging) +│ ├── MainActivity.kt # Main UI - lock control (291 lines) +│ ├── RegisterLockExampleActivity.kt # Lock registration workflow (158 lines) +│ ├── SignedTimeProvider.kt # Implements SDK time provider interface +│ ├── Constants.kt # Configuration (PERSONAL_ACCESS_KEY, presets) +│ │ +│ ├── api/ +│ │ ├── service/ +│ │ │ ├── MobileApi.kt # Retrofit API interface (5 endpoints) +│ │ │ ├── MobileService.kt # API response processing (80 lines) +│ │ │ └── ApiProvider.kt # Retrofit & HTTP client config (singleton) +│ │ └── data/model/ +│ │ ├── MobileCertificateResponse.kt +│ │ ├── RegisterMobileResponse.kt +│ │ ├── NewDoorLockResponse.kt +│ │ └── MobileRegistrationBody.kt +│ │ +│ ├── manager/ +│ │ ├── CertificateManager.kt # Certificate lifecycle management +│ │ ├── SignedTimeManager.kt # Time synchronization +│ │ ├── CreateDoorLockManager.kt # Lock creation API wrapper +│ │ └── SerialNumberManager.kt # Serial number retrieval +│ │ +│ ├── datastore/ +│ │ └── DataStoreManager.kt # Secure local storage (singleton) +│ │ +│ ├── helper/ +│ │ ├── UiSetupHelper.kt # UI initialization & management (291 lines) +│ │ └── UiHelper.kt # UI interface contract +│ │ +│ └── adapter/ +│ ├── BleResultsAdapter.kt # RecyclerView for command results +│ └── BleResultItem.kt # Result message data class +│ +├── app/src/main/res/ +│ ├── layout/ +│ │ ├── activity_main.xml # Main lock control UI +│ │ ├── activity_register_lock_example.xml +│ │ └── ble_result_item.xml +│ ├── values/ +│ │ ├── strings.xml +│ │ ├── colors.xml +│ │ └── themes.xml +│ └── mipmap-*/ # App icons (adaptive, multiple densities) +│ +├── README.md # User-facing documentation +├── ADD_LOCK_README.md # Lock registration tutorial +└── build.gradle # Dependencies and build config +``` + +## Architecture + +### Layered Architecture Pattern + +``` +┌─────────────────────────────────────┐ +│ PRESENTATION LAYER │ +│ MainActivity │ ← Implements ILockConnectionListener +│ RegisterLockExampleActivity │ ← Implements IAddLockConnectionListener +└────────────────┬────────────────────┘ + │ +┌────────────────▼────────────────────┐ +│ HELPER LAYER │ +│ UiSetupHelper │ ← UI orchestration & state management +│ UiHelper (interface) │ +└────────────────┬────────────────────┘ + │ +┌────────────────▼────────────────────┐ +│ BUSINESS LOGIC LAYER │ +│ CertificateManager │ ← Certificate lifecycle +│ SignedTimeManager │ ← Time synchronization +│ CreateDoorLockManager │ ← Lock creation +│ SerialNumberManager │ ← Serial lookup +└────────────────┬────────────────────┘ + │ +┌────────────────▼────────────────────┐ +│ SERVICE LAYER │ +│ MobileService │ ← API calls & response processing +│ Tedee Lock SDK │ ← BLE communication +└────────────────┬────────────────────┘ + │ +┌────────────────▼────────────────────┐ +│ EXTERNAL LAYER │ +│ MobileApi (Retrofit) │ ← HTTPS API (api.tedee.com) +│ LockConnectionManager (SDK) │ ← BLE operations +│ DataStoreManager │ ← Local encrypted storage +└─────────────────────────────────────┘ +``` + +## Key Dependencies + +```gradle +// Tedee Lock SDK - Core BLE communication +implementation('com.github.tedee-com:tedee-mobile-sdk-android:1.0.0@aar') { transitive = true } + +// Networking +implementation "com.squareup.retrofit2:retrofit:2.11.0" +implementation "com.squareup.retrofit2:converter-gson:2.11.0" +implementation "com.squareup.okhttp3:logging-interceptor:4.12.0" + +// Storage +implementation "androidx.datastore:datastore-preferences:1.2.0" + +// Logging +implementation "com.jakewharton.timber:timber:5.0.1" + +// Android Framework +implementation 'androidx.core:core-ktx:1.15.0' +implementation 'androidx.appcompat:appcompat:1.7.1' +implementation 'com.google.android.material:material:1.12.0' +implementation 'androidx.constraintlayout:constraintlayout:2.2.0' +``` + +**Note:** AndroidX Core 1.15.0 requires `compileSdk 35` or higher. + +## Core Data Flows + +### 1. Lock Connection Flow (Secure) + +``` +User Input (Serial Number, Certificate) + ↓ +Validate Certificate (or generate if needed) + ├─ CertificateManager.registerAndGenerateCertificate() + ├─ POST /api/v1.32/my/mobile + ├─ GET /api/v1.32/my/devicecertificate/getformobile + └─ DataStoreManager.saveCertificateData() + ↓ +MainActivity.setupSecureConnectClickListener() + ↓ +LockConnectionManager.connect(serialNumber, deviceCertificate, keepConnection, listener) + ↓ +ILockConnectionListener callbacks: + ├─ onLockConnectionChanged(isConnecting, isConnected) + ├─ onNotification(message: ByteArray) + ├─ onLockStatusChanged(lockStatus: Int) + └─ onError(throwable: Throwable) +``` + +### 2. Lock Registration Flow (Add New Lock) + +``` +RegisterLockExampleActivity.onCreate() + ↓ +Get Serial Number from Activation Code + └─ GET /api/v1.32/my/device/getserialnumber?activationCode=X + ↓ +AddLockConnectionManager.connectForAdding(serialNumber, false, listener) + ↓ +Wait for onUnsecureConnectionChanged(isConnected=true) + ↓ +Set Signed Date/Time + ├─ GET /api/v1.32/datetime/getsignedtime + └─ AddLockConnectionManager.setSignedTime(signedTime) + ↓ +Wait for NOTIFICATION_SIGNED_DATETIME + ↓ +Register Lock + ├─ AddLockConnectionManager.getAddLockData(activationCode, serialNumber) + ├─ POST /api/v1.32/my/Lock (with CreateDoorLockData) + ├─ Create RegisterDeviceData from response + └─ AddLockConnectionManager.registerDevice(registerDeviceData) + ↓ +Lock added to account (can now establish secure connection) +``` + +### 3. Command Execution Flow + +``` +User clicks control button (Open/Close/Pull Spring) + ↓ +lifecycleScope.launch { ... } + ↓ +LockConnectionManager.openLock(openMode) // or sendCommand(bytes) + ↓ +SDK handles secure BLE transmission + ↓ +ILockConnectionListener.onNotification(message) + ↓ +Parse response and update UI + └─ UiSetupHelper.addMessage(message) + └─ BleResultsAdapter displays in RecyclerView +``` + +## Key Conventions & Patterns + +### 1. Listener Pattern for BLE Events + +**MainActivity** implements `ILockConnectionListener`: +```kotlin +override fun onLockConnectionChanged(isConnecting: Boolean, isConnected: Boolean) +override fun onNotification(message: ByteArray) +override fun onLockStatusChanged(lockStatus: Int) +override fun onError(throwable: Throwable) +``` + +**RegisterLockExampleActivity** implements `IAddLockConnectionListener`: +```kotlin +override fun onUnsecureConnectionChanged(isConnecting: Boolean, isConnected: Boolean) +override fun onNotification(message: ByteArray) +override fun onError(throwable: Throwable) +``` + +### 2. Singleton Pattern + +Used for stateless services and providers: +- `ApiProvider` - Single Retrofit instance +- `DataStoreManager` - Single DataStore instance + +```kotlin +object DataStoreManager { ... } +object ApiProvider { ... } +``` + +### 3. Manager Pattern + +Business logic encapsulated in dedicated manager classes: +- Each manager has a single responsibility +- Managers coordinate between services and UI +- Use suspend functions for async operations + +### 4. Coroutines for Async Operations + +Always use `lifecycleScope.launch`: +```kotlin +lifecycleScope.launch { + try { + val result = mobileService.getCertificate(mobileId, deviceId) + // Update UI + } catch (e: Exception) { + // Handle error + } +} +``` + +Use `withContext(Dispatchers.IO)` for DataStore operations: +```kotlin +suspend fun saveCertificate(cert: String) { + withContext(Dispatchers.IO) { + dataStore.edit { preferences -> + preferences[CERTIFICATE_KEY] = cert + } + } +} +``` + +### 5. View Binding + +All activities use view binding (enabled in build.gradle): +```kotlin +private lateinit var binding: ActivityMainBinding + +override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + binding = ActivityMainBinding.inflate(layoutInflater) + setContentView(binding.root) +} +``` + +### 6. Timber Logging + +Use Timber for all logging (initialized in ExampleApplication): +```kotlin +Timber.d("Debug message") +Timber.e(exception, "Error occurred") +Timber.w("Warning message") +``` + +### 7. Error Handling Pattern + +**API Layer:** +```kotlin +try { + val response = api.someEndpoint() + if (response.isSuccessful) { + return extractResult(response.body()) + } else { + throw Exception("Error: ${response.errorBody()?.string()}") + } +} catch (error: Exception) { + throw error +} +``` + +**UI Layer:** +```kotlin +lifecycleScope.launch { + try { + // Async operation + } catch (e: Exception) { + Toast.makeText(this@Activity, e.message, Toast.LENGTH_SHORT).show() + Timber.e(e, "Operation failed") + } +} +``` + +## Development Workflows + +### Adding a New Lock Control Command + +1. **Check Tedee BLE API Documentation** for command byte code +2. **Add button to activity_main.xml** in the commands section +3. **Add click listener in UiSetupHelper.kt**: + ```kotlin + private fun setupNewCommandClickListener() { + binding.buttonNewCommand.setOnClickListener { + lifecycleScope.launch { + try { + val result = lockConnectionManager.sendCommand(byteArrayOf(0xXX)) + addMessage("Command sent: ${result?.print()}") + } catch (e: Exception) { + Toast.makeText(activity, e.message, Toast.LENGTH_SHORT).show() + } + } + } + } + ``` +4. **Call setup function** from `UiSetupHelper.setup()` +5. **Handle response** in `MainActivity.onNotification()` + +### Adding a New API Endpoint + +1. **Add endpoint to MobileApi.kt**: + ```kotlin + @GET("api/v1.32/my/newEndpoint") + suspend fun getNewData(@Query("param") param: String): Response + ``` + +2. **Add service method in MobileService.kt**: + ```kotlin + suspend fun getNewData(param: String): NewDataResponse { + val response = ApiProvider.api.getNewData(param) + if (response.isSuccessful) { + val result = response.body()?.getAsJsonObject("result") + return gson.fromJson(result, NewDataResponse::class.java) + } else { + throw Exception("Error: ${response.errorBody()?.string()}") + } + } + ``` + +3. **Create data model** in `api/data/model/`: + ```kotlin + data class NewDataResponse( + val field1: String, + val field2: Int + ) + ``` + +4. **Use in activity or manager**: + ```kotlin + lifecycleScope.launch { + try { + val data = mobileService.getNewData("value") + // Use data + } catch (e: Exception) { + Timber.e(e, "Failed to fetch data") + } + } + ``` + +### Storing New Configuration Data + +1. **Add key to DataStoreManager.kt**: + ```kotlin + private val NEW_DATA_KEY = stringPreferencesKey("new_data") + + suspend fun saveNewData(value: String) { + withContext(Dispatchers.IO) { + dataStore.edit { it[NEW_DATA_KEY] = value } + } + } + + suspend fun getNewData(): String? { + return withContext(Dispatchers.IO) { + dataStore.data.first()[NEW_DATA_KEY] + } + } + ``` + +2. **Use in code**: + ```kotlin + lifecycleScope.launch { + DataStoreManager.saveNewData("value") + val value = DataStoreManager.getNewData() + } + ``` + +## Configuration Requirements + +### Before Running the App + +Users must configure in `Constants.kt`: +```kotlin +object Constants { + const val PERSONAL_ACCESS_KEY: String = "" // Required from portal.tedee.com + + // Optional presets (auto-populate UI fields) + const val PRESET_SERIAL_NUMBER = "" + const val PRESET_DEVICE_ID = "" + const val PRESET_NAME = "" + const val PRESET_ACTIVATION_CODE = "" +} +``` + +### Getting Personal Access Key + +1. Log in to https://portal.tedee.com +2. Click on initials (top right) +3. Navigate to "Personal Access Keys" +4. Generate key with **Device certificates - Read** scope minimum +5. Paste into `Constants.PERSONAL_ACCESS_KEY` + +### Lock Information Sources (from Tedee App) + +- **Serial Number**: Lock > Settings > Information > Serial number +- **Device ID**: Lock > Settings > Information > Device ID +- **Lock Name**: Lock > Settings > Lock name +- **Activation Code**: Physical device or instruction manual + +## Permission Handling + +### Required Permissions (AndroidManifest.xml) + +```xml + + + +``` + +### Runtime Permission Request + +```kotlin +override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + requestPermissions(getBluetoothPermissions().toTypedArray(), 9) +} +``` + +**Note:** Location permissions are required by Android for BLE scanning (not for actual GPS). + +## Important Files Reference + +### Entry Points +- `MainActivity.kt:291` - Main lock control interface +- `RegisterLockExampleActivity.kt:158` - Lock registration workflow +- `ExampleApplication.kt` - App initialization + +### Core Business Logic +- `CertificateManager.kt:68` - Certificate generation and storage +- `UiSetupHelper.kt:291` - All UI initialization and event handling +- `MobileService.kt:80` - API communication layer + +### Configuration +- `Constants.kt` - All configuration constants +- `app/build.gradle` - Dependencies and SDK versions +- `AndroidManifest.xml` - Permissions and activity declarations + +### Resources +- `layout/activity_main.xml` - Main UI layout +- `strings.xml` - UI strings and spinner options +- `colors.xml` - Theme colors (includes midnight_blue #22345a) + +## Testing Considerations + +### Current State +- Unit tests: `src/test/` - Not populated (example only) +- Instrumented tests: `src/androidTest/` - Not populated (example only) + +### Recommended Testing Approach +1. **BLE Mocking**: Use mock implementations of `LockConnectionManager` +2. **API Mocking**: Use MockWebServer for Retrofit testing +3. **UI Testing**: Espresso for activity interactions +4. **Unit Testing**: Test managers and data models in isolation + +### Testing Constraints +- BLE cannot be tested in emulator +- Requires physical lock hardware for integration testing +- API requires valid Personal Access Key + +## Common Pitfalls & Important Notes + +### 1. Certificate Expiration +Certificates have expiration dates. If connection fails: +- Check certificate expiration in response +- Regenerate certificate if expired +- Certificates are deleted on app uninstall + +### 2. Single Lock Limitation +SDK supports **only one lock connection at a time**: +- Must disconnect before connecting to another lock +- Always call `lockConnectionManager.clear()` in `onDestroy()` + +### 3. BLE Requires Physical Device +- Android emulators don't support BLE +- Must test on physical Android device with BLE capability +- USB debugging must be enabled + +### 4. RxJava Error Handler +MainActivity sets up error handler for undelivered BLE exceptions: +```kotlin +RxJavaPlugins.setErrorHandler { throwable -> + if (throwable is UndeliverableException && throwable.cause is BleException) { + return@setErrorHandler + } + throw throwable +} +``` + +### 5. Keep Connection Parameter +`LockConnectionManager.connect()` has `keepConnection` parameter: +- `true`: Maintains indefinite connection +- `false`: Limited time connection (default) +- Controlled by switch in UI + +### 6. API Authentication Format +```kotlin +Authorization: PersonalKey [YOUR_PERSONAL_ACCESS_KEY] +``` +Not `Bearer`, use `PersonalKey` prefix. + +### 7. Notification Parsing +BLE notifications are ByteArray. Use SDK extension functions: +```kotlin +override fun onNotification(message: ByteArray) { + Timber.d("NOTIFICATION: ${message.print()}") // Hex string + if (message.first() == BluetoothConstants.NOTIFICATION_SIGNED_DATETIME) { + if (message.component2() == BluetoothConstants.API_RESULT_SUCCESS) { + // Success + } + } +} +``` + +### 8. Lifecycle Management +Always clean up in `onDestroy()`: +```kotlin +override fun onDestroy() { + lockConnectionManager.clear() + super.onDestroy() +} +``` + +### 9. Kotlin 2.1.0 Type Safety +Kotlin 2.1.0 has stricter type checking for byte literals. When using BLE commands: + +**Correct** - Use `.toByte()` for hex literals: +```kotlin +lockConnectionManager.sendCommand(0x51.toByte()) // ✓ Correct +``` + +**Incorrect** - Will cause type mismatch error: +```kotlin +lockConnectionManager.sendCommand(byteArrayOf(0x51)) // ✗ Wrong - expects Byte, not ByteArray +lockConnectionManager.sendCommand(0x51) // ✗ Wrong - expects Byte, not Int +``` + +**Important for Cylinder Commands:** +- Open (unlock): `sendCommand(0x51.toByte())` +- Close (lock): `sendCommand(0x50.toByte())` +- Pull spring: `sendCommand(0x52.toByte())` + +Note: SDK helper methods like `openLock()` and `closeLock()` may not work with cylinders. Use direct BLE commands instead. + +## API Reference + +### Tedee API Base URL +`https://api.tedee.com/` + +### Endpoints Used (API v1.32) + +| Method | Endpoint | Purpose | +|--------|----------|---------| +| POST | `/api/v1.32/my/mobile` | Register mobile device | +| GET | `/api/v1.32/my/devicecertificate/getformobile` | Get certificate for lock | +| GET | `/api/v1.32/datetime/getsignedtime` | Get synchronized time | +| GET | `/api/v1.32/my/device/getserialnumber` | Get serial from activation code | +| POST | `/api/v1.32/my/Lock` | Add lock to account | + +### Response Format +All responses follow this structure: +```json +{ + "result": { /* actual data */ }, + "success": true, + "errorMessages": [] +} +``` + +MobileService extracts the `result` object. + +## Tedee Lock SDK Reference + +### Main Classes from SDK + +**LockConnectionManager** - Secure connection +```kotlin +fun connect(serialNumber: String, deviceCertificate: DeviceCertificate, + keepConnection: Boolean, listener: ILockConnectionListener) +fun disconnect() +fun sendCommand(command: ByteArray): ByteArray? +fun openLock(openMode: Int = 0): ByteArray? +fun closeLock(closeMode: Int = 0): ByteArray? +fun pullSpring(): ByteArray? +fun getUnsecureDeviceSettings(): ByteArray? +fun getUnsecureFirmwareVersion(): ByteArray? +fun clear() +``` + +**AddLockConnectionManager** - Registration +```kotlin +fun connectForAdding(serialNumber: String, keepConnection: Boolean, + listener: IAddLockConnectionListener) +suspend fun setSignedTime(signedTime: SignedTime): ByteArray? +suspend fun getAddLockData(activationCode: String, serialNumber: String): CreateDoorLockData? +suspend fun registerDevice(registerDeviceData: RegisterDeviceData) +fun clear() +``` + +**Interfaces to Implement** +- `ILockConnectionListener` - For secure connections +- `IAddLockConnectionListener` - For registration +- `ISignedTimeProvider` - Provides current signed time + +## Build & Deployment + +### Build Configuration +```gradle +android { + compileSdk 35 + minSdk 26 + targetSdk 35 + + defaultConfig { + versionCode 2 + versionName "2.0" + } + + buildFeatures { + viewBinding true + } + + compileOptions { + sourceCompatibility JavaVersion.VERSION_17 + targetCompatibility JavaVersion.VERSION_17 + } + + kotlinOptions { + jvmTarget = '17' + } +} +``` + +**Important:** +- Java 17 is required for modern Android development (AGP 8.7.3, Kotlin 2.1.0) +- `compileSdk 35` is required by AndroidX Core 1.15.0 +- `jvmTarget` must match Java version for Kotlin compatibility + +### Build Variants +- **Debug**: Full debug info, debug signing +- **Release**: No minification (minifyEnabled false), debug signing + +### Building the App +```bash +# From Android Studio: Run > Run 'app' or Shift+F10 +# From command line: +./gradlew assembleDebug +./gradlew assembleRelease +``` + +## Useful Resources + +- **Tedee Lock SDK Documentation**: https://tedee-com.github.io/tedee-mobile-sdk-android/ +- **Tedee Lock BLE API Documentation**: https://tedee-tedee-lock-ble-api-doc.readthedocs-hosted.com/ +- **Tedee API Swagger**: https://api.tedee.com +- **Tedee Portal**: https://portal.tedee.com +- **Main README**: `README.md` - Complete setup instructions +- **Lock Registration Tutorial**: `ADD_LOCK_README.md` - Step-by-step guide + +## Quick Command Reference + +### BLE Command Codes - Complete List + +All commands require a PTLS (secure) session unless noted otherwise. + +#### **Operations Commands** +- `0x50` - **LOCK** - Lock the lock + - Parameters: `0x00` = None, `0x02` = Force (emergency) +- `0x51` - **UNLOCK** - Unlock the lock + - Parameters: `0x00` = None, `0x01` = Auto, `0x02` = Force (emergency) +- `0x52` - **PULL_SPRING** - Pull the spring latch + - Parameters: None + +#### **State & Information Commands** +- `0x5A` - **GET_STATE** - Get current lock state and jam status + - Returns: Lock state (unlocked/locked/pulling/etc.) + jam status +- `0x0C` - **GET_BATTERY** - Get battery level and charging status + - Returns: Battery % (0-100) + charging status (0=discharging, 1=charging) + +#### **Calibration Commands** +- `0x53` - **CALIBRATION_INIT** - Initialize calibration process +- `0x54` - **CALIBRATE_LOCKED** - Calibrate locked position +- `0x55` - **CALIBRATE_UNLOCKED** - Calibrate unlocked position +- `0x56` - **CALIBRATION_CANCEL** - Cancel calibration + +#### **Pull Spring Calibration Commands** +- `0x57` - **PULL_CALIBRATION_INIT** - Initialize pull spring calibration +- `0x58` - **PULL_CALIBRATION_START** - Start pull spring calibration +- `0x59` - **PULL_CALIBRATION_CANCEL** - Cancel pull spring calibration + +#### **Activity Logs Commands** +- `0x2D` - **GET_LOGS_TLV** - Retrieve activity logs in TLV format + - Result codes: + - `0x00` = SUCCESS (more logs available) + - `0x04` = NOT_FOUND (last/empty package) + - `0x03` = BUSY (retry in 100-500ms) + - `0x02` = ERROR (MTU too small) + - `0x07` = NO_PERMISSION + +#### **Security Commands** +- `0x71` - **SET_SIGNED_DATETIME** - Set trusted date/time from API + +### BLE Notifications (from Lock to App) + +Notifications are sent by the lock to inform about state changes and events: + +- `0xA5` - **HAS_LOGS** - Activity logs are ready to be collected + - Triggered after connection, indicates logs waiting to download +- `0xBA` - **LOCK_STATUS_CHANGE** - Lock state has changed + - Contains current lock state and operation result + +### Lock States (in notifications) +- `0x00` - **UNCALIBRATED** - Lock not calibrated +- `0x01` - **CALIBRATION** - Calibration in progress +- `0x02` - **UNLOCKED** - Lock is unlocked (opened) +- `0x03` - **PARTIALLY_UNLOCKED** - Partially unlocked position +- `0x04` - **UNLOCKING** - Unlock operation in progress +- `0x05` - **LOCKING** - Lock operation in progress +- `0x06` - **LOCKED** - Lock is locked (closed) +- `0x07` - **PULL_SPRING** - Pull spring position +- `0x08` - **PULLING** - Pull spring operation in progress +- `0x09` - **UNKNOWN** - Unknown state + +### Common Result Codes +- `0x00` - **SUCCESS** - Operation accepted/successful +- `0x01` - **INVALID_PARAM** - Invalid parameters +- `0x02` - **ERROR** - Operation error +- `0x03` - **BUSY** - Lock performing other operations +- `0x04` - **NOT_FOUND** - Resource not found +- `0x05` - **NOT_CALIBRATED** - Lock needs calibration +- `0x07` - **NO_PERMISSION** - Insufficient permissions +- `0x08` - **NOT_CONFIGURED** - Feature not configured +- `0x09` - **DISMOUNTED** - Lock not mounted on door + +### Important Notes + +**Cylinder vs Standard Lock:** +- Tedee **cylinders** use the codes listed above (confirmed working) +- Standard Tedee **locks** may use different command codes for some operations +- The app uses direct BLE commands (`sendCommand()`) for cylinder compatibility + +**SDK Methods:** +- `getDeviceSettings()` and `getFirmwareVersion()` are SDK helper methods +- These are NOT direct BLE commands but may use internal/undocumented codes +- They may only work during unsecured connection (device registration) + +## Code Style Guidelines + +### Kotlin Conventions +- Use `val` over `var` when possible +- Use data classes for models +- Use sealed classes for state representation +- Prefer coroutines over callbacks + +### Naming Conventions +- Activities: `*Activity.kt` +- Managers: `*Manager.kt` +- Data models: Descriptive nouns (e.g., `MobileCertificateResponse`) +- Interfaces: `I*` prefix (SDK convention) +- Constants: UPPER_SNAKE_CASE + +### Organization +- Group related functionality in packages +- Keep activities focused on UI logic +- Extract business logic to managers +- Use helpers for complex UI setup + +## Summary for AI Assistants + +When working with this codebase: + +1. **Always check README.md and ADD_LOCK_README.md** for user-facing documentation +2. **Respect the layered architecture** - don't bypass layers +3. **Use coroutines** for all async operations (lifecycleScope.launch) +4. **Handle errors gracefully** with try-catch and user-friendly messages +5. **Follow the listener pattern** for BLE events +6. **Remember single lock limitation** - SDK supports one connection at a time +7. **Use Timber for logging**, not println or Log +8. **Test on physical devices** - emulator doesn't support BLE +9. **Check certificate expiration** when connection issues occur +10. **Reference Tedee BLE API docs** for command bytes and responses + +This is an example/demo app - prioritize clarity and simplicity over production-grade complexity. diff --git a/README.md b/README.md index a8fb0f8..6435270 100644 --- a/README.md +++ b/README.md @@ -2,11 +2,25 @@ ![lock](https://user-images.githubusercontent.com/81370389/209109383-c9163001-cc5b-418b-be65-87906a3cc11c.jpg) +--- + +## 🆕 Flutter App Available + +**This repository now includes a fully-featured Flutter application** with advanced UI and background service support: + +📱 **Location:** [`flutter_app/`](./flutter_app/) +✨ **Features:** Swipe gestures, background service, auto-actions, battery monitoring, activity logs +📖 **Documentation:** [Flutter App README](./flutter_app/README.md) + +**Recommended for new development** - The Flutter app provides a modern, cross-platform foundation with enhanced features beyond the native Android example. + +--- + ## Documentation [Mobile SDK Documentation](https://tedee-com.github.io/tedee-mobile-sdk-android/) -## About +## About (Native Android Example) This example project was created by the [Tedee](https://tedee.com) team to show you how to operate the Tedee Lock using Bluetooth Low Energy communication protocol. This project is developed using the Kotlin language and is designed to run on Android devices. It utilizes our Tedee Lock SDK, which can be accessed via the following link: [LINK TO SDK](https://github.com/tedee-com/tedee-mobile-sdk-android). diff --git a/app/build.gradle b/app/build.gradle index 6b608ce..54cf2af 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -5,14 +5,14 @@ plugins { android { namespace 'tedee.mobile.demo' - compileSdk 34 + compileSdk 35 defaultConfig { applicationId "tedee.mobile.demo" minSdk 26 - targetSdk 34 - versionCode 1 - versionName "1.0" + targetSdk 35 + versionCode 2 + versionName "2.0" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" } @@ -25,11 +25,11 @@ android { } } compileOptions { - sourceCompatibility JavaVersion.VERSION_1_8 - targetCompatibility JavaVersion.VERSION_1_8 + sourceCompatibility JavaVersion.VERSION_17 + targetCompatibility JavaVersion.VERSION_17 } kotlinOptions { - jvmTarget = '1.8' + jvmTarget = '17' } buildFeatures { viewBinding true @@ -38,16 +38,17 @@ android { dependencies { implementation "com.jakewharton.timber:timber:${timberVersion}" - implementation 'androidx.core:core-ktx:1.12.0' - implementation 'androidx.appcompat:appcompat:1.6.1' - implementation 'com.google.android.material:material:1.11.0' - implementation 'androidx.constraintlayout:constraintlayout:2.1.4' - implementation "com.squareup.retrofit2:retrofit:2.9.0" - implementation "com.squareup.retrofit2:converter-gson:2.9.0" - implementation "com.squareup.retrofit2:adapter-rxjava2:2.9.0" - implementation "com.squareup.retrofit2:converter-scalars:2.9.0" - implementation "com.squareup.okhttp3:logging-interceptor:4.11.0" - implementation "androidx.datastore:datastore-preferences:1.0.0" + implementation 'androidx.core:core-ktx:1.15.0' + implementation 'androidx.core:core-splashscreen:1.0.1' + implementation 'androidx.appcompat:appcompat:1.7.1' + implementation 'com.google.android.material:material:1.12.0' + implementation 'androidx.constraintlayout:constraintlayout:2.2.0' + implementation "com.squareup.retrofit2:retrofit:2.11.0" + implementation "com.squareup.retrofit2:converter-gson:2.11.0" + implementation "com.squareup.retrofit2:adapter-rxjava2:2.11.0" + implementation "com.squareup.retrofit2:converter-scalars:2.11.0" + implementation "com.squareup.okhttp3:logging-interceptor:4.12.0" + implementation "androidx.datastore:datastore-preferences:1.2.0" //Tedee Lock SDK implementation('com.github.tedee-com:tedee-mobile-sdk-android:1.0.0@aar') { transitive = true } diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 8ac6f51..22147a7 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -11,6 +11,7 @@ android:name="android.permission.ACCESS_COARSE_LOCATION" tools:node="remove" /> + - - - - - + android:theme="@style/Theme.TedeeDemo.SplashScreen"> + if (throwable is UndeliverableException && throwable.cause is BleException) { return@setErrorHandler // ignore BleExceptions since we do not have subscriber @@ -45,10 +83,29 @@ class MainActivity : AppCompatActivity(), throw throwable } } - requestPermissions(getBluetoothPermissions().toTypedArray(), 9) + + // Request permissions sequentially + if (!hasBluetoothPermissions()) { + requestPermissions(getBluetoothPermissions().toTypedArray(), BLUETOOTH_PERMISSION_REQUEST_CODE) + } else { + // If Bluetooth permissions already granted, request notification permission + requestNotificationPermissionIfNeeded() + } + lockConnectionManager.signedDateTimeProvider = SignedTimeProvider(lifecycleScope, uiSetupHelper) uiSetupHelper.setup() - uiSetupHelper.setupSecureConnectClickListener(lockConnectionManager::connect) + uiSetupHelper.setupSecureConnectClickListener { serialNumber, deviceCertificate, keepConnection, listener -> + if (!hasBluetoothPermissions()) { + Toast.makeText( + this, + "Please grant Bluetooth permissions first", + Toast.LENGTH_LONG + ).show() + requestPermissions(getBluetoothPermissions().toTypedArray(), BLUETOOTH_PERMISSION_REQUEST_CODE) + return@setupSecureConnectClickListener + } + lockConnectionManager.connect(serialNumber, deviceCertificate, keepConnection, listener) + } uiSetupHelper.setupDisconnectClickListener(lockConnectionManager::disconnect) uiSetupHelper.setupSendCommandClickListener { message, params -> lifecycleScope.launch { @@ -76,7 +133,9 @@ class MainActivity : AppCompatActivity(), uiSetupHelper.setupOpenLockClickListener { lifecycleScope.launch { try { - lockConnectionManager.openLock() + // Use direct BLE command 0x51 for cylinder unlock + val result = lockConnectionManager.sendCommand(0x51.toByte()) + uiSetupHelper.addMessage("Open lock command sent: ${result?.print()}") } catch (e: Exception) { uiSetupHelper.onFailureRequest(e) } @@ -85,7 +144,9 @@ class MainActivity : AppCompatActivity(), uiSetupHelper.setupCloseLockClickListener { lifecycleScope.launch { try { - lockConnectionManager.closeLock() + // Use direct BLE command 0x50 for cylinder lock + val result = lockConnectionManager.sendCommand(0x50.toByte()) + uiSetupHelper.addMessage("Close lock command sent: ${result?.print()}") } catch (e: Exception) { uiSetupHelper.onFailureRequest(e) } @@ -94,7 +155,9 @@ class MainActivity : AppCompatActivity(), uiSetupHelper.setupPullLockClickListener { lifecycleScope.launch { try { - lockConnectionManager.pullSpring() + // Use direct BLE command 0x52 for pull spring + val result = lockConnectionManager.sendCommand(0x52.toByte()) + uiSetupHelper.addMessage("Pull spring command sent: ${result?.print()}") } catch (e: Exception) { uiSetupHelper.onFailureRequest(e) } @@ -109,29 +172,41 @@ class MainActivity : AppCompatActivity(), } } } - uiSetupHelper.setupGetDeviceSettingsClickListener(lockConnectionManager::getDeviceSettings) + uiSetupHelper.setupDownloadActivityLogsClickListener(lockConnectionManager::sendCommand) + uiSetupHelper.setupGetBatteryClickListener(lockConnectionManager::sendCommand) uiSetupHelper.setupGetFirmwareVersionClickListener(lockConnectionManager::getFirmwareVersion) binding.buttonNavigateToAddDevice.setOnClickListener { val intent = Intent(this@MainActivity, RegisterLockExampleActivity::class.java) startActivity(intent) finish() } + + Timber.d("MainActivity onCreate completed successfully") } override fun onLockConnectionChanged(isConnecting: Boolean, isConnected: Boolean) { Timber.w("LOCK LISTENER: secure connection changed: isConnected: $isConnected") + this.isConnected = isConnected uiSetupHelper.setCommandsSectionVisibility(false) uiSetupHelper.setAddingDeviceSectionVisibility(isVisible = false, isSecureConnected = true) when { - isConnecting -> uiSetupHelper.changeConnectingState("Connecting...", Color.WHITE) + isConnecting -> { + uiSetupHelper.changeConnectingState("Connecting...", Color.WHITE) + stopBatteryRefresh() + } isConnected -> { uiSetupHelper.changeConnectingState("Secure session established", Color.GREEN) uiSetupHelper.setCommandsSectionVisibility(true) uiSetupHelper.setAddingDeviceSectionVisibility(isVisible = true, isSecureConnected = true) + // Start battery refresh when connected + startBatteryRefresh() } - else -> uiSetupHelper.changeConnectingState("Disconnected", Color.RED) + else -> { + uiSetupHelper.changeConnectingState("Disconnected", Color.RED) + stopBatteryRefresh() + } } } @@ -140,8 +215,53 @@ class MainActivity : AppCompatActivity(), override fun onNotification(message: ByteArray) { if (message.isEmpty()) return Timber.d("LOCK LISTENER: notification: ${message.print()}") - val readableNotification = message.getReadableLockNotification() - val formattedText = "onNotification: \n$readableNotification" + + // Detailed hex dump for debugging + val hexBytes = message.joinToString(" ") { byte -> "0x%02X".format(byte) } + Timber.d("LOCK LISTENER: notification bytes: $hexBytes") + + // Check for HAS_ACTIVITY_LOGS notification (0xA5) + val firstByte = message.first() + val formattedText = when { + firstByte == 0xA5.toByte() -> { + """ + 📋 HAS_ACTIVITY_LOGS (0xA5) + + Activity logs are ready to be collected from the lock. + You can download them using the GET_LOGS_TLV command (0x2D). + + - Triggered after connection + - Indicates logs waiting to download + """.trimIndent() + } + else -> { + val readableNotification = message.getReadableLockNotification() + + // Add detailed info for unknown notifications + if (readableNotification.contains("unknown", ignoreCase = true)) { + val firstByteHex = "0x%02X".format(firstByte.toInt() and 0xFF) + val secondByteInfo = if (message.size > 1) { + val secondByte = message[1] + val secondByteHex = "0x%02X".format(secondByte.toInt() and 0xFF) + "$secondByte ($secondByteHex)" + } else { + "N/A" + } + """ + onNotification: $readableNotification + + DEBUG INFO: + - First byte (command): $firstByte ($firstByteHex) + - Second byte (status): $secondByteInfo + - Total bytes: ${message.size} + - Full hex: $hexBytes + """.trimIndent() + } else { + "onNotification: \n$readableNotification" + } + } + } + uiSetupHelper.addMessage(formattedText) } @@ -171,7 +291,117 @@ class MainActivity : AppCompatActivity(), } } + private fun hasBluetoothPermissions(): Boolean { + val bluetoothPermissions = getBluetoothPermissions() + return bluetoothPermissions.all { permission -> + ContextCompat.checkSelfPermission(this, permission) == PackageManager.PERMISSION_GRANTED + } + } + + private fun requestNotificationPermissionIfNeeded() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + val notificationPermission = Manifest.permission.POST_NOTIFICATIONS + if (ContextCompat.checkSelfPermission(this, notificationPermission) != PackageManager.PERMISSION_GRANTED) { + requestPermissions(arrayOf(notificationPermission), NOTIFICATION_PERMISSION_REQUEST_CODE) + } + } + } + + override fun onRequestPermissionsResult( + requestCode: Int, + permissions: Array, + grantResults: IntArray + ) { + super.onRequestPermissionsResult(requestCode, permissions, grantResults) + when (requestCode) { + BLUETOOTH_PERMISSION_REQUEST_CODE -> { + val allGranted = grantResults.isNotEmpty() && grantResults.all { it == PackageManager.PERMISSION_GRANTED } + if (allGranted) { + Timber.d("Bluetooth permissions granted") + // Request notification permission after Bluetooth permissions + requestNotificationPermissionIfNeeded() + } else { + Timber.w("Bluetooth permissions denied") + Toast.makeText( + this, + "Bluetooth permissions are required for BLE communication", + Toast.LENGTH_LONG + ).show() + } + } + NOTIFICATION_PERMISSION_REQUEST_CODE -> { + val granted = grantResults.isNotEmpty() && grantResults[0] == PackageManager.PERMISSION_GRANTED + if (granted) { + Timber.d("Notification permission granted") + } else { + Timber.w("Notification permission denied") + } + } + } + } + + private fun startBatteryRefresh() { + Timber.d("Starting battery refresh") + // Cancel any existing job + batteryRefreshJob?.cancel() + + // Start new refresh job + batteryRefreshJob = lifecycleScope.launch { + // First update immediately + try { + updateBatteryLevel() + } catch (e: Exception) { + Timber.e(e, "Error in initial battery update") + } + + // Then continue updating every 30 seconds + while (isActive && isConnected) { + try { + delay(30000) // Wait 30 seconds + updateBatteryLevel() + } catch (e: Exception) { + Timber.e(e, "Error refreshing battery") + delay(30000) // Continue trying even after error + } + } + Timber.d("Battery refresh loop ended") + } + } + + private fun stopBatteryRefresh() { + batteryRefreshJob?.cancel() + batteryRefreshJob = null + binding.batteryLevel.visibility = android.view.View.GONE + } + + @SuppressLint("SetTextI18n") + private suspend fun updateBatteryLevel() { + try { + Timber.d("Requesting battery level...") + val response = lockConnectionManager.sendCommand(0x0C.toByte(), null) + + Timber.d("Battery response: ${response?.print() ?: "null"}, size: ${response?.size ?: 0}") + + if (response != null && response.size >= 4) { + val batteryLevel = response[2].toInt() and 0xFF + val chargingStatus = response[3].toInt() and 0xFF + val chargingIcon = if (chargingStatus == 1) "⚡" else "🔋" + + // lifecycleScope already runs on main thread, no need for runOnUiThread + binding.batteryLevel.text = "$chargingIcon Battery: $batteryLevel%" + binding.batteryLevel.visibility = android.view.View.VISIBLE + Timber.d("Battery updated successfully: $batteryLevel% charging=$chargingStatus") + } else { + Timber.w("Battery response is null or too short: ${response?.size ?: 0} bytes") + } + } catch (e: Exception) { + Timber.e(e, "Failed to update battery level") + // Don't show battery if there's an error, keep it hidden + } + } + override fun onDestroy() { + stopBatteryRefresh() lockConnectionManager.clear() super.onDestroy() } diff --git a/app/src/main/java/tedee/mobile/demo/RegisterLockExampleActivity.kt b/app/src/main/java/tedee/mobile/demo/RegisterLockExampleActivity.kt index 95ed239..9071fca 100644 --- a/app/src/main/java/tedee/mobile/demo/RegisterLockExampleActivity.kt +++ b/app/src/main/java/tedee/mobile/demo/RegisterLockExampleActivity.kt @@ -1,5 +1,6 @@ package tedee.mobile.demo +import android.bluetooth.BluetoothManager import android.content.Intent import android.os.Bundle import android.widget.Toast @@ -34,6 +35,20 @@ class RegisterLockExampleActivity : AppCompatActivity(), IAddLockConnectionListe super.onCreate(savedInstanceState) binding = ActivityRegisterLockExampleBinding.inflate(layoutInflater) setContentView(binding.root) + + // Check if Bluetooth is enabled + val bluetoothManager = getSystemService(BLUETOOTH_SERVICE) as? BluetoothManager + val bluetoothAdapter = bluetoothManager?.adapter + if (bluetoothAdapter == null || !bluetoothAdapter.isEnabled) { + Toast.makeText( + this, + "Bluetooth is disabled. Please enable Bluetooth to use this app.", + Toast.LENGTH_LONG + ).show() + Timber.w("Bluetooth is disabled or not available") + return + } + requestPermissions(getBluetoothPermissions().toTypedArray(), 9) lifecycleScope.launch { try { diff --git a/app/src/main/java/tedee/mobile/demo/helper/UiSetupHelper.kt b/app/src/main/java/tedee/mobile/demo/helper/UiSetupHelper.kt index d302bdb..c637844 100644 --- a/app/src/main/java/tedee/mobile/demo/helper/UiSetupHelper.kt +++ b/app/src/main/java/tedee/mobile/demo/helper/UiSetupHelper.kt @@ -116,17 +116,127 @@ class UiSetupHelper( binding.buttonSetSignedTime.setOnClickListener { getAndSetSignedTime(setSignedTime) } } - fun setupGetDeviceSettingsClickListener(getDeviceSettings: suspend (Boolean) -> DeviceSettings?) { - binding.buttonGetDeviceSettings.setOnClickListener { + fun setupDownloadActivityLogsClickListener(sendCommand: suspend (Byte, ByteArray?) -> ByteArray?) { + binding.buttonDownloadActivityLogs.setOnClickListener { lifecycleScope.launch { try { - val deviceSettings = getDeviceSettings(isSecureConnected) - Timber.d("Device settings: $deviceSettings") - Toast.makeText(context, "$deviceSettings", Toast.LENGTH_SHORT).show() - } catch (e: DeviceNeedsResetError) { - Timber.e(e, "Device settings: DeviceNeedsResetError = $e") + val allLogs = mutableListOf() + var packageCount = 0 + var resultCode: Byte + + addMessage("📋 Starting activity logs download...") + + // Loop to download all log packages + do { + packageCount++ + val response = sendCommand(0x2D.toByte(), null) + + if (response == null || response.size < 2) { + addMessage("❌ Invalid response from lock") + return@launch + } + + // Debug: Log the full response + val hexBytes = response.joinToString(" ") { byte -> + "0x%02X".format(byte.toInt() and 0xFF) + } + Timber.d("GET_LOGS response: $hexBytes (size=${response.size})") + + // Response format: [COMMAND_ECHO, RESULT_CODE, DATA...] + // Byte 0: 0x2D (command echo) + // Byte 1: Result code + val commandEcho = response[0] + resultCode = response[1] + + Timber.d("Command echo: 0x%02X, Result code: 0x%02X".format( + commandEcho.toInt() and 0xFF, + resultCode.toInt() and 0xFF + )) + + when (resultCode) { + 0x00.toByte() -> { + // SUCCESS - more logs available + val logData = response.print() + allLogs.add("Package $packageCount (MORE): $logData") + Timber.d("Activity logs package $packageCount: $logData") + } + 0x04.toByte() -> { + // NOT_FOUND - last package or no logs + if (response.size > 1) { + val logData = response.print() + allLogs.add("Package $packageCount (LAST): $logData") + Timber.d("Activity logs last package: $logData") + } else { + allLogs.add("Package $packageCount: No more logs available") + } + } + 0x03.toByte() -> { + // BUSY - wait and retry + allLogs.add("Package $packageCount: Lock busy, waiting 200ms...") + kotlinx.coroutines.delay(200) + } + 0x02.toByte() -> { + addMessage("❌ MTU too small (minimum 98 bytes required)") + return@launch + } + 0x07.toByte() -> { + addMessage("❌ No permission to read logs") + return@launch + } + else -> { + val codeHex = "0x%02X".format(resultCode.toInt() and 0xFF) + addMessage("❌ Unknown result code: $codeHex\nFull response: $hexBytes") + return@launch + } + } + + // Safety limit to prevent infinite loops + if (packageCount >= 100) { + allLogs.add("⚠️ Stopped after 100 packages (safety limit)") + break + } + + } while (resultCode != 0x04.toByte()) // Continue until NOT_FOUND + + val summary = """ + |📋 Activity Logs Downloaded + | + |Total packages: $packageCount + |${allLogs.joinToString("\n")} + """.trimMargin() + + addMessage(summary) + } catch (e: Exception) { + addMessage("❌ Failed to download logs: ${e.message}") + Timber.e(e, "Error downloading activity logs") + } + } + } + } + + fun setupGetBatteryClickListener(sendCommand: suspend (Byte, ByteArray?) -> ByteArray?) { + binding.buttonGetBattery.setOnClickListener { + lifecycleScope.launch { + try { + // GET_BATTERY command (0x0C) + val response = sendCommand(0x0C.toByte(), null) + + if (response == null || response.size < 4) { + addMessage("❌ Invalid battery response") + return@launch + } + + // Response: [COMMAND_ECHO, RESULT, BATTERY_LEVEL, CHARGING_STATUS] + val batteryLevel = response[2].toInt() and 0xFF + val chargingStatus = response[3].toInt() and 0xFF + val chargingText = if (chargingStatus == 1) "⚡ Charging" else "🔌 Discharging" + + val batteryInfo = "🔋 Battery: $batteryLevel% - $chargingText" + addMessage(batteryInfo) + Timber.d("Battery info: $batteryLevel% charging=$chargingStatus") } catch (e: Exception) { - Timber.e(e, "Device settings: Other exception = $e") + addMessage("❌ Failed to get battery: ${e.message}") + Timber.e(e, "Error getting battery") } } } diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml index ac8bcf7..4048c45 100644 --- a/app/src/main/res/layout/activity_main.xml +++ b/app/src/main/res/layout/activity_main.xml @@ -145,6 +145,15 @@ android:textSize="14sp" tools:text="Connecting" /> + +