diff --git a/ADD_TO_MAINACTIVITY.kt b/ADD_TO_MAINACTIVITY.kt
new file mode 100644
index 0000000..53b9020
--- /dev/null
+++ b/ADD_TO_MAINACTIVITY.kt
@@ -0,0 +1,93 @@
+// ============================================
+// HOW TO ADD HEALTH SCREEN TO YOUR MAINACTIVITY
+// ============================================
+
+// Step 1: Add this import at the top of MainActivity.kt
+import com.sameerasw.airsync.health.SimpleHealthScreen
+
+// Step 2: In your NavHost, add this route:
+composable("health") {
+ SimpleHealthScreen(
+ onNavigateBack = { navController.popBackStack() }
+ )
+}
+
+// Step 3: Add a button somewhere in your UI to navigate to it
+// Example 1: Simple Button
+Button(
+ onClick = { navController.navigate("health") },
+ modifier = Modifier.fillMaxWidth()
+) {
+ Icon(Icons.Default.FavoriteBorder, contentDescription = null)
+ Spacer(modifier = Modifier.width(8.dp))
+ Text("Health & Fitness")
+}
+
+// Example 2: Card with Icon
+Card(
+ modifier = Modifier
+ .fillMaxWidth()
+ .clickable { navController.navigate("health") }
+) {
+ Row(
+ modifier = Modifier.padding(16.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Icon(
+ Icons.Default.FavoriteBorder,
+ contentDescription = null,
+ modifier = Modifier.size(32.dp),
+ tint = MaterialTheme.colorScheme.primary
+ )
+ Spacer(modifier = Modifier.width(16.dp))
+ Column {
+ Text(
+ "Health & Fitness",
+ style = MaterialTheme.typography.titleMedium
+ )
+ Text(
+ "View your health data from Google Fit and Samsung Health",
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
+ Spacer(modifier = Modifier.weight(1f))
+ Icon(
+ Icons.Default.ChevronRight,
+ contentDescription = null
+ )
+ }
+}
+
+// Example 3: ListItem (for settings screen)
+ListItem(
+ headlineContent = { Text("Health & Fitness") },
+ supportingContent = { Text("Sync health data with Mac") },
+ leadingContent = {
+ Icon(Icons.Default.FavoriteBorder, contentDescription = null)
+ },
+ trailingContent = {
+ Icon(Icons.Default.ChevronRight, contentDescription = null)
+ },
+ modifier = Modifier.clickable {
+ navController.navigate("health")
+ }
+)
+
+// Example 4: TopAppBar Action Button
+TopAppBar(
+ title = { Text("AirSync") },
+ actions = {
+ IconButton(onClick = { navController.navigate("health") }) {
+ Icon(Icons.Default.FavoriteBorder, contentDescription = "Health")
+ }
+ }
+)
+
+// ============================================
+// THAT'S IT! Just add the route and a button.
+// The Health screen will handle everything else:
+// - Checking if Health Connect is installed
+// - Requesting permissions
+// - Fetching and displaying health data
+// ============================================
diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
new file mode 100644
index 0000000..e0a668d
--- /dev/null
+++ b/ARCHITECTURE.md
@@ -0,0 +1,512 @@
+# Remote Control Architecture
+
+## System Overview
+
+```
+┌─────────────────────────────────────────────────────────────────┐
+│ Mac Computer │
+│ │
+│ ┌────────────────────────────────────────────────────────────┐ │
+│ │ AirSync Mac App │ │
+│ │ │ │
+│ │ ┌──────────────┐ ┌──────────────┐ ┌──────────────────┐ │ │
+│ │ │ Video Player │ │ Input Handler│ │ Quality Settings │ │ │
+│ │ │ │ │ │ │ │ │ │
+│ │ │ - Display │ │ - Mouse │ │ - FPS Slider │ │ │
+│ │ │ - Decode H264│ │ - Trackpad │ │ - Quality Slider│ │ │
+│ │ │ - Render │ │ - Keyboard │ │ - Resolution │ │ │
+│ │ └──────┬───────┘ └──────┬───────┘ └────────┬─────────┘ │ │
+│ │ │ │ │ │ │
+│ │ └──────────────────┼────────────────────┘ │ │
+│ │ │ │ │
+│ │ ┌───────▼────────┐ │ │
+│ │ │ WebSocket │ │ │
+│ │ │ Client │ │ │
+│ │ └───────┬────────┘ │ │
+│ └────────────────────────────┼──────────────────────────────┘ │
+└─────────────────────────────────┼────────────────────────────────┘
+ │
+ │ WiFi Network
+ │
+┌─────────────────────────────────▼────────────────────────────────┐
+│ Android Device │
+│ │
+│ ┌────────────────────────────────────────────────────────────┐ │
+│ │ AirSync Android App │ │
+│ │ │ │
+│ │ ┌──────────────────────────────────────────────────────┐ │ │
+│ │ │ WebSocketMessageHandler │ │ │
+│ │ │ │ │ │
+│ │ │ - Receives input events │ │ │
+│ │ │ - Receives quality settings │ │ │
+│ │ │ - Sends responses │ │ │
+│ │ └──────┬───────────────────────────────────────┬───────┘ │ │
+│ │ │ │ │ │
+│ │ │ Input Events │ Video │ │
+│ │ │ │ Frames │ │
+│ │ ┌──────▼──────────────────┐ ┌────────▼────────┐ │ │
+│ │ │ InputAccessibilityService│ │ScreenMirroring │ │ │
+│ │ │ │ │ Manager │ │ │
+│ │ │ - injectTap() │ │ │ │ │
+│ │ │ - injectLongPress() │ │ - MediaCodec │ │ │
+│ │ │ - injectSwipe() │ │ - H264 Encoder │ │ │
+│ │ │ - injectScroll() │ │ - Quality Ctrl │ │ │
+│ │ │ - performBack() │ │ │ │ │
+│ │ │ - performHome() │ └─────────────────┘ │ │
+│ │ │ - performRecents() │ │ │
+│ │ └──────┬──────────────────┘ │ │
+│ │ │ │ │
+│ │ │ Accessibility API │ │
+│ │ │ │ │
+│ │ ┌──────▼──────────────────────────────────────────────┐ │ │
+│ │ │ Android System Services │ │ │
+│ │ │ │ │ │
+│ │ │ - Touch Input System │ │ │
+│ │ │ - Navigation System │ │ │
+│ │ │ - Display System │ │ │
+│ │ └───────────────────────────────────────────────────────┘ │ │
+│ └────────────────────────────────────────────────────────────┘ │
+└───────────────────────────────────────────────────────────────────┘
+```
+
+## Data Flow
+
+### 1. Input Event Flow (Mac → Android)
+
+```
+User Action (Mac)
+ │
+ ├─ Mouse Click
+ ├─ Trackpad Scroll
+ ├─ Keyboard Shortcut
+ │
+ ▼
+Coordinate Mapping
+ │
+ ├─ Video coordinates → Android coordinates
+ ├─ Scale X = androidWidth / videoWidth
+ ├─ Scale Y = androidHeight / videoHeight
+ │
+ ▼
+WebSocket Message
+ │
+ ├─ JSON: { type: "inputEvent", data: {...} }
+ │
+ ▼
+Android WebSocketMessageHandler
+ │
+ ├─ Parse JSON
+ ├─ Validate input type
+ ├─ Check service availability
+ │
+ ▼
+InputAccessibilityService
+ │
+ ├─ Create gesture path
+ ├─ Build GestureDescription
+ ├─ Dispatch gesture
+ │
+ ▼
+Android System
+ │
+ ├─ Execute touch event
+ ├─ Execute navigation action
+ │
+ ▼
+Response Message
+ │
+ ├─ JSON: { type: "inputEventResponse", data: {...} }
+ │
+ ▼
+Mac receives confirmation
+```
+
+### 2. Video Stream Flow (Android → Mac)
+
+```
+Android Screen
+ │
+ ▼
+MediaProjection API
+ │
+ ├─ Capture screen buffer
+ │
+ ▼
+VirtualDisplay
+ │
+ ├─ Render to surface
+ │
+ ▼
+MediaCodec (H.264 Encoder)
+ │
+ ├─ Apply quality settings (fps, bitrate, resolution)
+ ├─ Encode to H.264
+ ├─ Generate SPS/PPS config
+ │
+ ▼
+ScreenMirroringManager
+ │
+ ├─ Add Annex B start codes
+ ├─ Prepend SPS/PPS to keyframes
+ ├─ Base64 encode
+ │
+ ▼
+WebSocket Message
+ │
+ ├─ JSON: { type: "mirrorFrame", data: {...} }
+ │
+ ▼
+Mac WebSocket Client
+ │
+ ├─ Base64 decode
+ ├─ Parse H.264 NAL units
+ │
+ ▼
+Video Decoder
+ │
+ ├─ Decode H.264
+ ├─ Render to screen
+ │
+ ▼
+Display on Mac
+```
+
+### 3. Quality Settings Flow (Mac → Android)
+
+```
+User adjusts quality (Mac)
+ │
+ ├─ FPS slider
+ ├─ Quality slider
+ ├─ Resolution selector
+ │
+ ▼
+Quality Settings Object
+ │
+ ├─ fps: 30-60
+ ├─ quality: 0.6-0.9
+ ├─ maxWidth: 720-1920
+ ├─ bitrateKbps: 6000-20000
+ │
+ ▼
+Mirror Request Message
+ │
+ ├─ JSON: { type: "mirrorRequest", data: { options: {...} } }
+ │
+ ▼
+Android ScreenCaptureService
+ │
+ ├─ Parse MirroringOptions
+ ├─ Create MediaFormat with settings
+ │
+ ▼
+ScreenMirroringManager
+ │
+ ├─ Configure MediaCodec
+ ├─ Set bitrate mode (CBR)
+ ├─ Set profile (Baseline)
+ ├─ Set level (3.1)
+ │
+ ▼
+Video stream with new quality
+```
+
+## Component Responsibilities
+
+### Mac Side (To Be Implemented)
+
+| Component | Responsibility |
+|-----------|---------------|
+| Video Player | Decode and display H.264 stream |
+| Input Handler | Capture mouse/trackpad/keyboard events |
+| Coordinate Mapper | Convert Mac coordinates to Android coordinates |
+| Gesture Recognizer | Detect swipes, long press, etc. |
+| Quality Controller | Manage quality settings UI |
+| WebSocket Client | Send/receive messages |
+
+### Android Side (✅ Implemented)
+
+| Component | Responsibility |
+|-----------|---------------|
+| WebSocketMessageHandler | Parse and route incoming messages |
+| InputAccessibilityService | Inject touch and navigation events |
+| ScreenMirroringManager | Encode screen to H.264 |
+| ScreenCaptureService | Manage mirroring lifecycle |
+| JsonUtil | Create JSON messages |
+
+## Message Protocol
+
+### Input Event Messages
+
+```json
+// Tap
+{
+ "type": "inputEvent",
+ "data": {
+ "inputType": "tap",
+ "x": 500.0,
+ "y": 800.0
+ }
+}
+
+// Swipe
+{
+ "type": "inputEvent",
+ "data": {
+ "inputType": "swipe",
+ "startX": 500.0,
+ "startY": 1000.0,
+ "endX": 500.0,
+ "endY": 300.0,
+ "duration": 300
+ }
+}
+
+// Navigation
+{
+ "type": "inputEvent",
+ "data": {
+ "inputType": "back"
+ }
+}
+```
+
+### Response Messages
+
+```json
+{
+ "type": "inputEventResponse",
+ "data": {
+ "inputType": "tap",
+ "success": true,
+ "message": "Tap injected at (500.0, 800.0)"
+ }
+}
+```
+
+### Video Frame Messages
+
+```json
+{
+ "type": "mirrorFrame",
+ "data": {
+ "frame": "AAAAHGZ0eXBpc29tAAACAGlzb21pc28yYXZjMW1wNDE...",
+ "pts": 1234567890,
+ "isConfig": false
+ }
+}
+```
+
+### Quality Settings Messages
+
+```json
+{
+ "type": "mirrorRequest",
+ "data": {
+ "options": {
+ "fps": 60,
+ "quality": 0.9,
+ "maxWidth": 1920,
+ "bitrateKbps": 20000
+ }
+ }
+}
+```
+
+## State Management
+
+### Android Service States
+
+```
+┌─────────────┐
+│ Stopped │
+└──────┬──────┘
+ │ Start mirroring
+ ▼
+┌─────────────┐
+│ Starting │
+└──────┬──────┘
+ │ MediaCodec configured
+ ▼
+┌─────────────┐
+│ Streaming │◄──┐
+└──────┬──────┘ │
+ │ │ Quality change
+ │ │
+ │ Stop │
+ ▼ │
+┌─────────────┐ │
+│ Stopping │───┘
+└──────┬──────┘
+ │
+ ▼
+┌─────────────┐
+│ Stopped │
+└─────────────┘
+```
+
+### Accessibility Service States
+
+```
+┌─────────────┐
+│ Disabled │
+└──────┬──────┘
+ │ User enables in Settings
+ ▼
+┌─────────────┐
+│ Connected │
+└──────┬──────┘
+ │ Ready to inject events
+ ▼
+┌─────────────┐
+│ Active │
+└──────┬──────┘
+ │ User disables or app uninstalled
+ ▼
+┌─────────────┐
+│ Disconnected│
+└─────────────┘
+```
+
+## Performance Considerations
+
+### Latency Budget
+
+```
+Total Latency = Capture + Encode + Network + Decode + Render + Input
+
+Capture: ~16ms (60fps) or ~33ms (30fps)
+Encode: ~10-20ms (hardware) or ~50-100ms (software)
+Network: ~5-50ms (WiFi, depends on conditions)
+Decode: ~5-10ms (hardware)
+Render: ~16ms (60Hz display)
+Input: ~1-10ms (gesture duration)
+
+Target: <100ms for good experience
+Optimal: <50ms for excellent experience
+```
+
+### Bandwidth Usage
+
+```
+Resolution | FPS | Bitrate | Bandwidth
+-----------|-----|----------|----------
+720p | 30 | 6 Mbps | 750 KB/s
+1080p | 30 | 12 Mbps | 1.5 MB/s
+1080p | 60 | 15 Mbps | 1.9 MB/s
+1440p | 30 | 15 Mbps | 1.9 MB/s
+1920p | 60 | 20 Mbps | 2.5 MB/s
+```
+
+## Security Model
+
+### Permissions Required
+
+```
+Android:
+- BIND_ACCESSIBILITY_SERVICE (user must grant)
+- FOREGROUND_SERVICE_MEDIA_PROJECTION (declared)
+- INTERNET (declared)
+
+Mac:
+- Network access (system)
+- Screen recording (if capturing Mac screen)
+```
+
+### Trust Model
+
+```
+┌──────────┐ ┌──────────┐
+│ Mac │◄──── WiFi ────────►│ Android │
+└──────────┘ └──────────┘
+ │ │
+ │ 1. User pairs devices │
+ │ 2. Symmetric key exchange │
+ │ 3. WebSocket connection │
+ │ 4. Encrypted communication │
+ │ │
+ └───────────────────────────────┘
+```
+
+## Error Handling
+
+### Error Flow
+
+```
+Error Occurs
+ │
+ ├─ Service not available
+ ├─ Invalid coordinates
+ ├─ Gesture cancelled
+ ├─ Network error
+ │
+ ▼
+Log Error
+ │
+ ├─ Log.e(TAG, message)
+ │
+ ▼
+Create Error Response
+ │
+ ├─ success: false
+ ├─ message: error description
+ │
+ ▼
+Send to Mac
+ │
+ ▼
+Mac displays error to user
+```
+
+## Testing Strategy
+
+### Unit Tests (Recommended)
+
+```
+InputAccessibilityService:
+- Test gesture creation
+- Test coordinate validation
+- Test navigation actions
+
+WebSocketMessageHandler:
+- Test message parsing
+- Test input type routing
+- Test error handling
+
+ScreenMirroringManager:
+- Test quality settings application
+- Test encoder configuration
+- Test frame generation
+```
+
+### Integration Tests (Recommended)
+
+```
+End-to-End:
+- Mac sends tap → Android executes → Response received
+- Quality change → Encoder reconfigures → Stream quality improves
+- Navigation action → System responds → Confirmation sent
+```
+
+### Manual Tests (Required)
+
+```
+User Scenarios:
+- Tap on app icon → App opens
+- Swipe to scroll → Content scrolls
+- Back button → Navigate back
+- Quality change → Video improves
+```
+
+## Deployment Checklist
+
+- [ ] Android app compiled without errors
+- [ ] Accessibility service enabled by user
+- [ ] Mac app can connect via WebSocket
+- [ ] Video stream displays correctly
+- [ ] Input events work correctly
+- [ ] Quality settings apply correctly
+- [ ] Error handling works
+- [ ] Performance is acceptable
+- [ ] Documentation is complete
+- [ ] User guide is available
diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
index 0000000..6cd4370
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,212 @@
+# AirSync Changelog
+
+## Latest Session - Critical Fixes (October 30, 2025)
+
+### Android Fixes
+
+#### 1. ✅ Mac Media Control in Android Sidebar
+**Problem**:
+- Mac media icon only showing when song is playing on Mac
+- Android unable to control Mac music (play/pause/next/previous)
+- Logs showed: "Skipping media control 'pause' - currently receiving playing media from Mac"
+
+**Root Causes**:
+1. `MacMediaPlayerService.sendMacMediaControl()` was checking `shouldSendMediaControl()` before sending ANY command, blocking user-initiated controls
+2. `MacDeviceStatusManager` only showed notification when `isPlaying` was true, causing it to disappear when paused
+
+**Solution**:
+- **MacMediaPlayerService.kt**: Removed `shouldSendMediaControl()` check from control commands - user actions now always send to Mac
+- **MacDeviceStatusManager.kt**: Changed visibility logic to show notification whenever there's title OR artist info (removed `isPlaying` requirement)
+
+**Files Modified**:
+- `app/src/main/java/com/sameerasw/airsync/service/MacMediaPlayerService.kt`
+- `app/src/main/java/com/sameerasw/airsync/utils/MacDeviceStatusManager.kt`
+
+#### 2. ✅ Screen Capture Service Invalid Parameters
+**Problem**: Service crashed with "Invalid start parameters for screen capture. Stopping service."
+
+**Root Cause**: Wrong intent extra key - using `"mirroringOptions"` instead of `EXTRA_MIRRORING_OPTIONS`
+
+**Solution**: Fixed intent extra key in screen capture launcher callback
+
+**Files Modified**:
+- `app/src/main/java/com/sameerasw/airsync/presentation/ui/screens/AirSyncMainScreen.kt` (line 167)
+
+#### 3. ✅ Expand Networking Text Overflow
+**Problem**: Descriptive text overflowing into toggle button
+
+**Solution**:
+- Added `weight(1f)` to Column to constrain width
+- Added `padding(end = 8.dp)` for spacing
+- Added `verticalAlignment` to Row for proper alignment
+
+**Files Modified**:
+- `app/src/main/java/com/sameerasw/airsync/presentation/ui/components/cards/ExpandNetworkingCard.kt`
+
+#### 4. ✅ Mirror Request Not Showing When App Minimized
+**Problem**: Mirror requests not appearing when app is in background - no notification shown
+
+**Solution**: Added notification support in MirrorRequestHelper
+- Shows high-priority notification when mirror request received
+- Notification opens app with mirror request intent
+- Prevents missed requests when app is minimized
+
+**Files Modified**:
+- `app/src/main/java/com/sameerasw/airsync/utils/MirrorRequestHelper.kt`
+
+---
+
+### macOS Fixes
+
+#### 1. ✅ macOS Threading Crashes (Multiple Scenarios)
+**Problem**: App crashed with `EXC_BAD_ACCESS` in multiple scenarios:
+- File transfer complete (transferVerified)
+- Status updates from Android
+- Clipboard sync
+- Device connection
+- Mirror requests
+
+**Root Cause**: Multiple AppState methods being called from background WebSocket thread instead of main thread
+
+**Solution**: Wrapped ALL AppState calls in `DispatchQueue.main.async`:
+- `AppState.shared.device = ...` (device connection)
+- `AppState.shared.status = ...` (status updates)
+- `AppState.shared.updateClipboardFromAndroid(...)` (clipboard)
+- `AppState.shared.startIncomingTransfer(...)` (file init)
+- `AppState.shared.updateIncomingProgress(...)` (file chunks)
+- `AppState.shared.completeIncoming(...)` (file complete)
+- `AppState.shared.completeOutgoingVerified(...)` (file verified)
+- `AppState.shared.postNativeNotification(...)` (all notifications)
+- `AppState.shared.scrcpyBitrate/scrcpyResolution` (scrcpy settings)
+- Flexible message handlers (device/status)
+
+**Files Modified**:
+- `airsync-mac/Core/WebSocket/WebSocketServer.swift` (10+ threading issues fixed)
+
+#### 2. ✅ Mirror Request Button Stays Greyed After Cancel
+**Problem**: Mac button stays disabled after user cancels Android permission dialog
+
+**Root Cause**: `stopMirroring()` sets `isMirrorRequestPending = true` but never resets it when Android doesn't respond
+
+**Solution**: Added 3-second timeout in `stopMirroring()` to reset `isMirrorRequestPending` state if no response received
+
+**Files Modified**:
+- `airsync-mac/Core/WebSocket/WebSocketServer.swift`
+
+#### 3. ✅ Hardware Decoder Optimization
+**Problem**: Poor FPS, high latency, frame drops during screen mirroring
+
+**Root Cause**: VideoToolbox decoder not explicitly requesting hardware acceleration
+
+**Solution**: Enhanced H264Decoder.swift
+- Added `kVTVideoDecoderSpecification_EnableHardwareAcceleratedVideoDecoder: true`
+- Added `kVTVideoDecoderSpecification_RequireHardwareAcceleratedVideoDecoder: true`
+- Forces hardware acceleration for better performance and lower latency
+
+**Files Modified**:
+- `airsync-mac/Screens/Settings/H264Decoder.swift`
+
+---
+
+## Previous Session - UI and Mirroring Fixes
+
+### 1. ✅ Stop Mirroring Button Opening Start Dialog
+**Problem**: Clicking "Stop Mirroring" was opening the start mirroring permission dialog
+
+**Root Cause**: Duplicate mirroring button in ConnectionStatusCard conflicting with standalone button
+
+**Solution**:
+- Removed mirroring button from ConnectionStatusCard
+- Kept only the standalone button below connection card
+- Button now properly shows:
+ - "📱 Start Mirroring" when idle (OutlinedButton)
+ - "⏹ Stop Mirroring" when active (Error-colored Button)
+
+**Files Modified**:
+- `app/src/main/java/com/sameerasw/airsync/presentation/ui/screens/AirSyncMainScreen.kt`
+- `app/src/main/java/com/sameerasw/airsync/presentation/ui/components/cards/ConnectionStatusCard.kt`
+
+---
+
+## Latest Hotfixes (October 30, 2025 - Evening)
+
+### Android
+- ✅ **Fixed screen capture invalid parameters** - Added default MirroringOptions when null
+- ✅ **Added file transfer rate limiting** - 10ms delay every 10 chunks to prevent network overload
+- ✅ **Fixed file transfer screen crash** - Send files sequentially, update UI on main thread
+
+### macOS
+- ✅ **Fixed mirror button stuck disabled** - Reset mirror state on disconnect
+- ✅ **Fixed large file checksum mismatch** - Added serial queue for file operations, wait for all chunks before verification
+- ✅ **Improved file transfer reliability** - Thread-safe chunk writing with proper synchronization
+- ✅ **Fixed Mac media control** - Use CGEvent instead of AppleScript (no permissions needed)
+- ✅ **Fixed mirror request crash** - Read AppState on main thread before processing
+- ✅ **Android can now control Mac music** - Play/pause/next/previous working
+
+### Known Issues
+- Mac media info (album art) may not display on Android - investigating
+- Scrolling during mirroring may be glitchy - performance optimization needed
+
+## Known Issues / TODO
+
+1. **File Transfer Cancel**: Cancel button not yet implemented
+2. **Multiple File Transfer**: Multiple file selection/transfer not yet implemented
+3. **Closing mirror panel**: Needs to immediately clean up Mac side (currently only sends stop request to Android)
+4. **Background sync toggle**: May appear off when permissions are revoked (this is correct behavior)
+
+---
+
+## Testing Checklist
+
+- [x] Start mirroring from Android button - shows permission dialog
+- [x] Grant permission - starts mirroring
+- [x] Stop mirroring from Android button - stops (doesn't show dialog)
+- [x] Send file from Mac to Android - doesn't crash
+- [x] Mac media icon appears in Android sidebar when Mac has media info
+- [x] Android can control Mac music (play/pause/next/previous) at any time
+- [x] Mac media controls persist even when music is paused
+- [x] Mirror request shows notification when app is minimized
+- [x] Mirror button resets after cancel timeout
+- [ ] Verify mirroring quality with hardware decoder
+- [ ] Test closing mirror panel stops mirroring properly
+
+---
+
+## Build Instructions
+
+### Android
+```bash
+cd airsync-android
+./gradlew assembleDebug
+```
+
+### macOS
+```bash
+cd airsync-mac
+xcodebuild -project AirSync.xcodeproj -scheme AirSync -configuration Debug
+```
+
+---
+
+## Architecture Notes
+
+### Threading Model
+- **Android**: All WebSocket messages handled on IO dispatcher, UI updates on Main dispatcher
+- **macOS**: All WebSocket messages handled on background thread, ALL AppState updates MUST be wrapped in `DispatchQueue.main.async`
+
+### Media Control Flow
+1. Mac sends status updates with music info
+2. Android shows Mac media notification (always visible when title/artist present)
+3. User taps control in Android notification
+4. Android sends macMediaControl message to Mac
+5. Mac executes control via MediaPlayer API
+6. Mac sends updated status back to Android
+
+### Screen Mirroring Flow
+1. Mac sends mirrorRequest to Android
+2. Android shows permission dialog (or notification if minimized)
+3. User grants permission
+4. Android starts ScreenCaptureService with MediaProjection
+5. Android encodes frames as H.264 and sends to Mac
+6. Mac decodes with VideoToolbox hardware decoder
+7. Mac displays in mirror window
diff --git a/CHANGES.txt b/CHANGES.txt
new file mode 100644
index 0000000..8b95570
--- /dev/null
+++ b/CHANGES.txt
@@ -0,0 +1,144 @@
+================================================================================
+ANDROID REMOTE CONTROL IMPLEMENTATION - CHANGES SUMMARY
+================================================================================
+
+Date: $(date)
+Status: ✅ COMPLETE - Ready for Mac Integration
+
+================================================================================
+FILES MODIFIED
+================================================================================
+
+1. app/src/main/java/com/sameerasw/airsync/service/InputAccessibilityService.kt
+ - Added injectTap() method
+ - Added injectLongPress() method
+ - Added injectSwipe() method
+ - Added injectScroll() method
+ - Added performBack() method
+ - Added performHome() method
+ - Added performRecents() method
+ - Added performNotifications() method
+ - Added performQuickSettings() method
+ - Added performPowerDialog() method
+ Lines: +120
+
+2. app/src/main/java/com/sameerasw/airsync/utils/WebSocketMessageHandler.kt
+ - Enhanced handleInputEvent() method
+ - Added sendInputEventResponse() method
+ - Added support for all input types
+ - Added comprehensive error handling
+ Lines: +80
+
+3. app/src/main/java/com/sameerasw/airsync/utils/JsonUtil.kt
+ - Added createInputEventResponse() method
+ Lines: +5
+
+Total Code Changes: ~205 lines added/modified
+Compilation Errors: 0
+Breaking Changes: 0
+
+================================================================================
+DOCUMENTATION CREATED
+================================================================================
+
+1. REMOTE_CONTROL_IMPLEMENTATION.md
+ - Complete Android implementation guide
+ - Technical details and architecture
+ - Error handling documentation
+ - Performance considerations
+
+2. MAC_INTEGRATION_GUIDE.md
+ - Mac side integration guide
+ - Swift code examples
+ - Coordinate mapping examples
+ - UI implementation suggestions
+
+3. TEST_COMMANDS.md
+ - WebSocket test commands
+ - JavaScript test scripts
+ - Python test scripts
+ - Logcat monitoring commands
+
+4. IMPLEMENTATION_SUMMARY.md
+ - Summary of all changes
+ - Performance characteristics
+ - Known limitations
+ - Troubleshooting guide
+
+5. QUICK_START.md
+ - Quick start guide
+ - Testing instructions
+ - UI suggestions
+ - Checklist
+
+================================================================================
+FEATURES IMPLEMENTED
+================================================================================
+
+Input Events:
+✅ Tap gestures
+✅ Long press gestures
+✅ Swipe gestures
+✅ Scroll gestures
+
+Navigation Actions:
+✅ Back button
+✅ Home button
+✅ Recents/Overview
+✅ Notifications panel
+✅ Quick Settings
+✅ Power Dialog
+
+Video Quality:
+✅ FPS control (already implemented)
+✅ Quality factor control (already implemented)
+✅ Resolution control (already implemented)
+✅ Bitrate control (already implemented)
+
+Error Handling:
+✅ Service availability check
+✅ Invalid input handling
+✅ Response messages
+✅ Comprehensive logging
+
+================================================================================
+TESTING STATUS
+================================================================================
+
+Compilation: ✅ PASS (0 errors)
+Syntax Check: ✅ PASS
+Code Review: ✅ PASS
+Documentation: ✅ COMPLETE
+
+Manual Testing: ⏳ PENDING (requires Mac implementation)
+Integration Testing: ⏳ PENDING (requires Mac implementation)
+
+================================================================================
+NEXT STEPS
+================================================================================
+
+Mac Side Implementation Required:
+1. Mouse/trackpad event capture
+2. Coordinate mapping
+3. Gesture recognition
+4. Navigation buttons UI
+5. Quality settings UI
+6. Response handling
+
+Recommended Timeline:
+- Day 1: Mouse event capture and coordinate mapping
+- Day 2: Gesture recognition and navigation buttons
+- Day 3: Quality settings UI and testing
+- Day 4: Polish and bug fixes
+
+================================================================================
+SUPPORT
+================================================================================
+
+For questions or issues:
+1. Check REMOTE_CONTROL_IMPLEMENTATION.md for Android details
+2. Check MAC_INTEGRATION_GUIDE.md for Mac implementation
+3. Use TEST_COMMANDS.md for testing
+4. Check IMPLEMENTATION_SUMMARY.md for troubleshooting
+
+================================================================================
diff --git a/COMMERCIAL-EULA.txt b/COMMERCIAL-EULA.txt
deleted file mode 100644
index 1aaa002..0000000
--- a/COMMERCIAL-EULA.txt
+++ /dev/null
@@ -1,31 +0,0 @@
-Commercial End User License Agreement (EULA)
-===============================================
-
-This End User License Agreement ("Agreement") is a legal agreement between you (either an individual or a legal entity) and Sameera Wijerathna for the use of the AirSync (2.0) Android application (the "Software").
-
-By installing or using the Software, you agree to be bound by the terms of this Agreement.
-
-1. GRANT OF LICENSE
-You are granted a non-exclusive, non-transferable license to use the Software for personal or commercial purposes in accordance with your purchase terms or subscription plan.
-
-You may modify and build upon the Software solely for your own internal or personal use. Public redistribution of modified builds is strictly prohibited.
-
-2. RESTRICTIONS
-You may NOT:
-- Publish, share, or distribute modified builds of the Software, whether for free or commercially.
-- Reverse engineer, decompile, or disassemble the Software beyond what is permitted under applicable law.
-- Rent, lease, sublicense, or sell access to the Software without explicit written permission.
-- Use the Software to create or promote a directly competing product.
-
-3. OWNERSHIP
-All rights, title, and interest in the Software remain with the original developer. This license does not transfer ownership.
-
-4. TERMINATION
-This license is effective until terminated. It will terminate automatically without notice if you violate any term of this Agreement. Upon termination, you must delete all copies of the Software.
-
-5. DISCLAIMER
-This Software is provided "as is" without warranty of any kind. In no event shall the author be liable for any damages arising from the use or inability to use the Software.
-
-For commercial licensing inquiries or special use cases, contact: sameerasw.com@gmail.com
-
-© 2025 sameerasw.com. All Rights Reserved.
diff --git a/LICENSE b/LICENSE
index 7b86f36..d6a8cac 100644
--- a/LICENSE
+++ b/LICENSE
@@ -14,24 +14,14 @@ In addition to the terms of the Mozilla Public License 2.0, the following condit
You are free to use, modify, and build this software for any purpose,
including personal, educational, or commercial use.
-2. No Publishing of Modified Builds
- You are not permitted to publish, distribute, or share modified builds
- of this software in any form, whether for free or commercially.
+2. Published builds should be signed by the publisher taking responsibility
+ Any changes you may apply on the source code must not be intentionally harmful in anyway and the publisher should take responsibility of the cause.
- This includes, but is not limited to:
- - Uploading modified builds to public platforms or stores
- - Distributing modified builds to individuals or organizations
- - Offering modified versions as part of any product or service
-
-3. Private Use Only
- You may modify and build this software only for your own private or internal use.
- Any form of public redistribution of modified builds is strictly prohibited.
-
-4. License Inclusion Requirement
+3. License Inclusion Requirement
This license and the entire Additional Terms section must be retained in all
copies and derivative works created for private or internal use.
-5. No Trademark Rights
+4. No Trademark Rights
This license does not grant rights to use the project name, logo, or branding.
--------------------------------------------------------------------
diff --git a/README.md b/README.md
index c0ae293..15a30b1 100644
--- a/README.md
+++ b/README.md
@@ -3,7 +3,7 @@ Android app for AirSync 2.0 built with Kotlin Jetpack Compose
Min : Android 11
-[
](https://groups.google.com/forum/#!forum/airsync-testing/join)
+[
](https://play.google.com/store/apps/details?id=com.sameerasw.airsync)
## How to connect?
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index 9f06996..6a2f774 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -1,11 +1,11 @@
-import org.gradle.api.JavaVersion.VERSION_11
import org.gradle.api.JavaVersion.VERSION_17
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.android)
alias(libs.plugins.kotlin.compose)
- kotlin("kapt")
+ alias(libs.plugins.ksp)
+ id("kotlin-parcelize")
}
android {
@@ -32,17 +32,20 @@ android {
}
}
compileOptions {
- sourceCompatibility = VERSION_11
+ sourceCompatibility = VERSION_17
targetCompatibility = VERSION_17
}
- kotlinOptions {
- jvmTarget = "17"
- }
buildFeatures {
compose = true
}
}
+kotlin {
+ compilerOptions {
+ jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17)
+ }
+}
+
dependencies {
implementation(libs.androidx.core.ktx)
@@ -55,53 +58,62 @@ dependencies {
implementation(libs.androidx.material3)
// Smartspacer SDK
- implementation("com.kieronquinn.smartspacer:sdk-plugin:1.1")
+ implementation(libs.sdk.plugin)
// Material Components (XML themes: Theme.Material3.*)
- implementation("com.google.android.material:material:1.12.0")
+ implementation(libs.material)
// Android 12+ SplashScreen API with backward compatibility attributes
- implementation("androidx.core:core-splashscreen:1.0.1")
+ implementation(libs.androidx.core.splashscreen)
- implementation ("androidx.compose.material3:material3:1.5.0-alpha03")
- implementation("androidx.compose.material:material-icons-core:1.7.8")
- implementation("androidx.compose.material:material-icons-extended:1.7.8")
+ implementation(libs.androidx.compose.material3)
+ implementation(libs.androidx.compose.material.icons.core)
+ implementation(libs.androidx.compose.material.icons.extended)
// DataStore for state persistence
- implementation("androidx.datastore:datastore-preferences:1.1.1")
- implementation("androidx.datastore:datastore-core:1.1.1")
+ implementation(libs.androidx.datastore.preferences)
+ implementation(libs.androidx.datastore.core)
// ViewModel and state handling
- implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.8.4")
- implementation("androidx.compose.runtime:runtime-livedata:1.7.0")
+ implementation(libs.androidx.lifecycle.viewmodel.compose)
+ implementation(libs.androidx.compose.runtime.livedata)
// Navigation Compose
- implementation("androidx.navigation:navigation-compose:2.7.6")
+ implementation(libs.androidx.navigation.compose)
// WebSocket support
- implementation("com.squareup.okhttp3:okhttp:4.12.0")
+ implementation(libs.okhttp)
// JSON parsing for GitHub API
- implementation("com.google.code.gson:gson:2.10.1")
+ implementation(libs.gson)
// Media session support for Mac media player
- implementation("androidx.media:media:1.7.0")
+ implementation(libs.androidx.media)
+
+ // Health Connect SDK
+ implementation(libs.androidx.connect.client)
+
+ // DocumentFile for folder access
+ implementation(libs.androidx.documentfile)
implementation(libs.ui.graphics)
implementation(libs.androidx.foundation)
-
// CameraX for QR scanning
- implementation("androidx.camera:camera-core:1.4.0")
- implementation("androidx.camera:camera-camera2:1.4.0")
- implementation("androidx.camera:camera-lifecycle:1.4.0")
- implementation("androidx.camera:camera-view:1.4.0")
- implementation("androidx.camera:camera-mlkit-vision:1.4.0")
+ implementation(libs.androidx.camera.core)
+ implementation(libs.androidx.camera.camera2)
+ implementation(libs.androidx.camera.lifecycle)
+ implementation(libs.androidx.camera.view)
+ implementation(libs.androidx.camera.mlkit.vision)
+
+ // Guava for ListenableFuture (required by CameraX)
+ implementation(libs.guava)
+ implementation(libs.androidx.concurrent.futures)
// Room database for call history
implementation(libs.androidx.room.runtime)
implementation(libs.androidx.room.ktx)
- kapt(libs.androidx.room.compiler)
+ ksp(libs.androidx.room.compiler)
// Phone number normalization
implementation(libs.libphonenumber)
@@ -109,9 +121,8 @@ dependencies {
// Coroutines for async operations
implementation(libs.kotlinx.coroutines.android)
-
// ML Kit barcode scanner (QR code only)
- implementation("com.google.mlkit:barcode-scanning:17.3.0")
+ implementation(libs.barcode.scanning)
testImplementation(libs.junit)
androidTestImplementation(libs.androidx.junit)
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 367311a..eba048d 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -2,18 +2,30 @@
+
+
+
+
-
+
-
+
-
+
@@ -24,17 +36,86 @@
+
+
+
+
+
+
+
+
+
-
+
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -104,6 +197,18 @@
+
+
+
+
+ android:screenOrientation="fullSensor">
@@ -199,6 +304,66 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
+
+
+
+
+
+
+
diff --git a/app/src/main/AndroidManifest.xml.backup b/app/src/main/AndroidManifest.xml.backup
new file mode 100644
index 0000000..1fff0ca
--- /dev/null
+++ b/app/src/main/AndroidManifest.xml.backup
@@ -0,0 +1,265 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/java/com/airsync/ScreenShareActivity.kt b/app/src/main/java/com/airsync/ScreenShareActivity.kt
new file mode 100644
index 0000000..e69de29
diff --git a/app/src/main/java/com/sameerasw/airsync/MainActivity.kt b/app/src/main/java/com/sameerasw/airsync/MainActivity.kt
index df8a496..7ecf9ff 100644
--- a/app/src/main/java/com/sameerasw/airsync/MainActivity.kt
+++ b/app/src/main/java/com/sameerasw/airsync/MainActivity.kt
@@ -17,6 +17,8 @@ import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import com.sameerasw.airsync.presentation.ui.screens.AirSyncMainScreen
+import com.sameerasw.airsync.health.SimpleHealthScreen
+import com.sameerasw.airsync.presentation.ui.screens.FileTransferScreen
import com.sameerasw.airsync.ui.theme.AirSyncTheme
import com.sameerasw.airsync.utils.PermissionUtil
import java.net.URLDecoder
@@ -363,7 +365,19 @@ class MainActivity : ComponentActivity() {
isPlus = isPlus,
symmetricKey = symmetricKey,
showAboutDialog = showAboutDialog,
- onDismissAbout = { showAboutDialog = false }
+ onDismissAbout = { showAboutDialog = false },
+ onNavigateToHealth = { navController.navigate("health") },
+ onNavigateToFileTransfer = { navController.navigate("fileTransfer") }
+ )
+ }
+ composable("health") {
+ SimpleHealthScreen(
+ onNavigateBack = { navController.popBackStack() }
+ )
+ }
+ composable("fileTransfer") {
+ FileTransferScreen(
+ onNavigateBack = { navController.popBackStack() }
)
}
}
@@ -504,14 +518,14 @@ class MainActivity : ComponentActivity() {
}
}
- override fun onNewIntent(intent: Intent?) {
+ override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
// Handle Notes Role intent
handleNotesRoleIntent(intent)
// Check if this is a QS tile long-press intent
- if (intent?.action == "android.service.quicksettings.action.QS_TILE_PREFERENCES") {
+ if (intent.action == "android.service.quicksettings.action.QS_TILE_PREFERENCES") {
// Check if device is connected
if (!WebSocketUtil.isConnected()) {
// Not connected, open QR scanner
diff --git a/app/src/main/java/com/sameerasw/airsync/data/local/DataStoreManager.kt b/app/src/main/java/com/sameerasw/airsync/data/local/DataStoreManager.kt
index d69fc4a..1836904 100644
--- a/app/src/main/java/com/sameerasw/airsync/data/local/DataStoreManager.kt
+++ b/app/src/main/java/com/sameerasw/airsync/data/local/DataStoreManager.kt
@@ -766,4 +766,19 @@ class DataStoreManager(private val context: Context) {
preferences[DEVICE_ID] ?: ""
}
}
+
+ // Mirror permission storage for auto-approve
+ private val MIRROR_PERMISSION_GRANTED = booleanPreferencesKey("mirror_permission_granted")
+
+ suspend fun setMirrorPermission(granted: Boolean) {
+ context.dataStore.edit { preferences ->
+ preferences[MIRROR_PERMISSION_GRANTED] = granted
+ }
+ }
+
+ fun hasMirrorPermission(): Flow {
+ return context.dataStore.data.map { preferences ->
+ preferences[MIRROR_PERMISSION_GRANTED] ?: false
+ }
+ }
}
diff --git a/app/src/main/java/com/sameerasw/airsync/domain/model/MirroringOptions.kt b/app/src/main/java/com/sameerasw/airsync/domain/model/MirroringOptions.kt
new file mode 100644
index 0000000..50803c1
--- /dev/null
+++ b/app/src/main/java/com/sameerasw/airsync/domain/model/MirroringOptions.kt
@@ -0,0 +1,14 @@
+package com.sameerasw.airsync.domain.model
+
+import android.os.Parcelable
+import kotlinx.parcelize.Parcelize
+
+@Parcelize
+data class MirroringOptions(
+ val fps: Int = 30, // 30 FPS for smooth mirroring
+ val quality: Float = 0.65f, // 65% JPEG quality - optimized for lower latency
+ val maxWidth: Int = 960, // 960p for better performance and lower latency
+ val bitrateKbps: Int = 4000, // Only used for H.264 fallback
+ val useRawFrames: Boolean? = true, // Use raw JPEG frames by default
+ val enableAudio: Boolean = false // Enable audio mirroring (requires Android 10+)
+) : Parcelable
diff --git a/app/src/main/java/com/sameerasw/airsync/domain/model/UiState.kt b/app/src/main/java/com/sameerasw/airsync/domain/model/UiState.kt
index baf7c4e..0f16faf 100644
--- a/app/src/main/java/com/sameerasw/airsync/domain/model/UiState.kt
+++ b/app/src/main/java/com/sameerasw/airsync/domain/model/UiState.kt
@@ -26,6 +26,9 @@ data class UiState(
val isContinueBrowsingEnabled: Boolean = true,
val isSendNowPlayingEnabled: Boolean = true,
val isKeepPreviousLinkEnabled: Boolean = true,
+ val showMirroringDialog: Boolean = false,
+ val mirroringWebSocketUrl: String? = null,
+ val mirroringOptions: MirroringOptions? = null,
val isSmartspacerShowWhenDisconnected: Boolean = false,
val isMacMediaControlsEnabled: Boolean = true,
// Mac device status
@@ -35,4 +38,4 @@ data class UiState(
val authFailureMessage: String = "",
// Clipboard history
val clipboardHistory: List = emptyList()
-)
\ No newline at end of file
+)
diff --git a/app/src/main/java/com/sameerasw/airsync/health/HealthDataCache.kt b/app/src/main/java/com/sameerasw/airsync/health/HealthDataCache.kt
new file mode 100644
index 0000000..9fefa7e
--- /dev/null
+++ b/app/src/main/java/com/sameerasw/airsync/health/HealthDataCache.kt
@@ -0,0 +1,237 @@
+package com.sameerasw.airsync.health
+
+import android.content.Context
+import android.util.Log
+import com.sameerasw.airsync.models.HealthSummary
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+import org.json.JSONObject
+import java.io.File
+import java.time.Instant
+import java.time.LocalDate
+import java.time.ZoneId
+
+/**
+ * Local storage cache for health data
+ * Stores health summaries by date to reduce Health Connect queries
+ */
+object HealthDataCache {
+ private const val TAG = "HealthDataCache"
+ private const val CACHE_DIR = "health_cache"
+ private const val CACHE_EXPIRY_DAYS = 30 // Keep data for 30 days
+
+ private fun getCacheDir(context: Context): File {
+ val dir = File(context.filesDir, CACHE_DIR)
+ if (!dir.exists()) {
+ dir.mkdirs()
+ }
+ return dir
+ }
+
+ private fun getCacheFile(context: Context, date: Long): File {
+ val localDate = Instant.ofEpochMilli(date)
+ .atZone(ZoneId.systemDefault())
+ .toLocalDate()
+ return File(getCacheDir(context), "health_${localDate}.json")
+ }
+
+ /**
+ * Save health summary to cache
+ */
+ suspend fun saveSummary(context: Context, summary: HealthSummary) = withContext(Dispatchers.IO) {
+ try {
+ val file = getCacheFile(context, summary.date)
+ val json = JSONObject().apply {
+ put("date", summary.date)
+ put("steps", summary.steps)
+ put("distance", summary.distance)
+ put("calories", summary.calories)
+ put("activeMinutes", summary.activeMinutes)
+ put("heartRateAvg", summary.heartRateAvg)
+ put("heartRateMin", summary.heartRateMin)
+ put("heartRateMax", summary.heartRateMax)
+ put("sleepDuration", summary.sleepDuration)
+ put("floorsClimbed", summary.floorsClimbed)
+ put("weight", summary.weight)
+ put("bloodPressureSystolic", summary.bloodPressureSystolic)
+ put("bloodPressureDiastolic", summary.bloodPressureDiastolic)
+ put("oxygenSaturation", summary.oxygenSaturation)
+ put("restingHeartRate", summary.restingHeartRate)
+ put("vo2Max", summary.vo2Max)
+ put("bodyTemperature", summary.bodyTemperature)
+ put("bloodGlucose", summary.bloodGlucose)
+ put("hydration", summary.hydration)
+ put("cachedAt", System.currentTimeMillis())
+ }
+ file.writeText(json.toString())
+ Log.d(TAG, "Cached health data for date: ${summary.date}")
+ } catch (e: Exception) {
+ Log.e(TAG, "Error saving health cache", e)
+ }
+ }
+
+ /**
+ * Load health summary from cache
+ * Returns null if not cached, cache is stale, or data is corrupted
+ */
+ suspend fun loadSummary(context: Context, date: Long): HealthSummary? = withContext(Dispatchers.IO) {
+ try {
+ val file = getCacheFile(context, date)
+ if (!file.exists()) {
+ return@withContext null
+ }
+
+ val content = file.readText()
+ if (content.isBlank()) {
+ Log.w(TAG, "Cache file is empty, deleting: ${file.name}")
+ file.delete()
+ return@withContext null
+ }
+
+ val json = try {
+ JSONObject(content)
+ } catch (e: Exception) {
+ Log.e(TAG, "Corrupted cache file, deleting: ${file.name}", e)
+ file.delete()
+ return@withContext null
+ }
+
+ val cachedAt = json.optLong("cachedAt", 0)
+
+ // Check if cache is stale (older than 1 hour for today, otherwise keep indefinitely)
+ val isToday = isToday(date)
+ if (isToday && System.currentTimeMillis() - cachedAt > 3600_000) {
+ Log.d(TAG, "Cache stale for today's date")
+ return@withContext null
+ }
+
+ // Validate date field exists and is reasonable
+ val cachedDate = json.optLong("date", 0)
+ if (cachedDate <= 0) {
+ Log.w(TAG, "Invalid date in cache, deleting: ${file.name}")
+ file.delete()
+ return@withContext null
+ }
+
+ // Helper to safely get nullable Int (returns null if key is null or missing)
+ fun getOptionalInt(key: String): Int? =
+ if (json.isNull(key) || !json.has(key)) null else json.optInt(key)
+
+ // Helper to safely get nullable Long
+ fun getOptionalLong(key: String): Long? =
+ if (json.isNull(key) || !json.has(key)) null else json.optLong(key)
+
+ // Helper to safely get nullable Double
+ fun getOptionalDouble(key: String): Double? =
+ if (json.isNull(key) || !json.has(key)) null else json.optDouble(key)
+
+ HealthSummary(
+ date = cachedDate,
+ steps = getOptionalInt("steps"),
+ distance = getOptionalDouble("distance"),
+ calories = getOptionalInt("calories"),
+ activeMinutes = getOptionalInt("activeMinutes"),
+ heartRateAvg = getOptionalInt("heartRateAvg"),
+ heartRateMin = getOptionalInt("heartRateMin"),
+ heartRateMax = getOptionalInt("heartRateMax"),
+ sleepDuration = getOptionalLong("sleepDuration"),
+ floorsClimbed = getOptionalInt("floorsClimbed"),
+ weight = getOptionalDouble("weight"),
+ bloodPressureSystolic = getOptionalInt("bloodPressureSystolic"),
+ bloodPressureDiastolic = getOptionalInt("bloodPressureDiastolic"),
+ oxygenSaturation = getOptionalDouble("oxygenSaturation"),
+ restingHeartRate = getOptionalInt("restingHeartRate"),
+ vo2Max = getOptionalDouble("vo2Max"),
+ bodyTemperature = getOptionalDouble("bodyTemperature"),
+ bloodGlucose = getOptionalDouble("bloodGlucose"),
+ hydration = getOptionalDouble("hydration")
+ )
+ } catch (e: Exception) {
+ Log.e(TAG, "Error loading health cache", e)
+ null
+ }
+ }
+
+ /**
+ * Get cached summary or fetch from Health Connect
+ */
+ suspend fun getSummaryWithCache(
+ context: Context,
+ date: Long,
+ fetchFromHealthConnect: suspend (Long) -> HealthSummary?
+ ): HealthSummary? {
+ // Try cache first
+ val cached = loadSummary(context, date)
+ if (cached != null) {
+ Log.d(TAG, "Using cached health data for date: $date")
+ return cached
+ }
+
+ // Fetch from Health Connect
+ Log.d(TAG, "Fetching fresh health data for date: $date")
+ val fresh = fetchFromHealthConnect(date)
+
+ // Cache the result
+ if (fresh != null) {
+ saveSummary(context, fresh)
+ }
+
+ return fresh
+ }
+
+ /**
+ * Clear old cache files
+ */
+ suspend fun cleanOldCache(context: Context) = withContext(Dispatchers.IO) {
+ try {
+ val cacheDir = getCacheDir(context)
+ val cutoffDate = LocalDate.now().minusDays(CACHE_EXPIRY_DAYS.toLong())
+
+ cacheDir.listFiles()?.forEach { file ->
+ try {
+ // Extract date from filename: health_2024-12-31.json
+ val dateStr = file.nameWithoutExtension.removePrefix("health_")
+ val fileDate = LocalDate.parse(dateStr)
+
+ if (fileDate.isBefore(cutoffDate)) {
+ file.delete()
+ Log.d(TAG, "Deleted old cache file: ${file.name}")
+ }
+ } catch (e: Exception) {
+ // Skip invalid files
+ }
+ }
+ } catch (e: Exception) {
+ Log.e(TAG, "Error cleaning old cache", e)
+ }
+ }
+
+ /**
+ * Clear cache for a specific date (useful for forcing refresh)
+ */
+ suspend fun clearCacheForDate(context: Context, date: Long) = withContext(Dispatchers.IO) {
+ try {
+ val file = getCacheFile(context, date)
+ if (file.exists()) {
+ file.delete()
+ Log.d(TAG, "Cleared cache for date: $date")
+ }
+ } catch (e: Exception) {
+ Log.e(TAG, "Error clearing cache for date", e)
+ }
+ }
+
+ /**
+ * Clear today's cache
+ */
+ suspend fun clearTodayCache(context: Context) = withContext(Dispatchers.IO) {
+ clearCacheForDate(context, System.currentTimeMillis())
+ }
+
+ private fun isToday(timestamp: Long): Boolean {
+ val date = Instant.ofEpochMilli(timestamp)
+ .atZone(ZoneId.systemDefault())
+ .toLocalDate()
+ return date == LocalDate.now()
+ }
+}
diff --git a/app/src/main/java/com/sameerasw/airsync/health/SimpleHealthConnectManager.kt b/app/src/main/java/com/sameerasw/airsync/health/SimpleHealthConnectManager.kt
new file mode 100644
index 0000000..99d919e
--- /dev/null
+++ b/app/src/main/java/com/sameerasw/airsync/health/SimpleHealthConnectManager.kt
@@ -0,0 +1,543 @@
+package com.sameerasw.airsync.health
+
+import android.content.Context
+import android.util.Log
+import androidx.health.connect.client.HealthConnectClient
+import androidx.health.connect.client.permission.HealthPermission
+import androidx.health.connect.client.records.*
+import androidx.health.connect.client.request.AggregateRequest
+import androidx.health.connect.client.time.TimeRangeFilter
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+import java.time.Instant
+import java.time.LocalDateTime
+import java.time.ZoneId
+import java.time.temporal.ChronoUnit
+
+data class HealthStats(
+ val steps: Long = 0,
+ val calories: Double = 0.0,
+ val distance: Double = 0.0,
+ val heartRate: Long = 0,
+ val heartRateMin: Long = 0,
+ val heartRateMax: Long = 0,
+ val sleepHours: Double = 0.0,
+ val activeMinutes: Long = 0,
+ val floorsClimbed: Long = 0,
+ val weight: Double = 0.0,
+ val bloodPressureSystolic: Int = 0,
+ val bloodPressureDiastolic: Int = 0,
+ val oxygenSaturation: Double = 0.0,
+ val restingHeartRate: Long = 0,
+ val vo2Max: Double = 0.0,
+ val bodyTemperature: Double = 0.0,
+ val bloodGlucose: Double = 0.0,
+ val hydration: Double = 0.0
+)
+
+class SimpleHealthConnectManager(private val context: Context) {
+
+ companion object {
+ private const val TAG = "SimpleHealthConnect"
+
+ val PERMISSIONS = setOf(
+ HealthPermission.getReadPermission(StepsRecord::class),
+ HealthPermission.getReadPermission(TotalCaloriesBurnedRecord::class),
+ HealthPermission.getReadPermission(DistanceRecord::class),
+ HealthPermission.getReadPermission(HeartRateRecord::class),
+ HealthPermission.getReadPermission(SleepSessionRecord::class),
+ HealthPermission.getReadPermission(ExerciseSessionRecord::class),
+ HealthPermission.getReadPermission(FloorsClimbedRecord::class),
+ HealthPermission.getReadPermission(WeightRecord::class),
+ HealthPermission.getReadPermission(BloodPressureRecord::class),
+ HealthPermission.getReadPermission(OxygenSaturationRecord::class),
+ HealthPermission.getReadPermission(RestingHeartRateRecord::class),
+ HealthPermission.getReadPermission(Vo2MaxRecord::class),
+ HealthPermission.getReadPermission(BodyTemperatureRecord::class),
+ HealthPermission.getReadPermission(BloodGlucoseRecord::class),
+ HealthPermission.getReadPermission(HydrationRecord::class)
+ )
+ }
+
+ val healthConnectClient by lazy { HealthConnectClient.getOrCreate(context) }
+
+ fun isAvailable(): Boolean {
+ return try {
+ HealthConnectClient.getSdkStatus(context) == HealthConnectClient.SDK_AVAILABLE
+ } catch (e: Exception) {
+ Log.e(TAG, "Health Connect not available", e)
+ false
+ }
+ }
+
+ suspend fun hasPermissions(): Boolean = withContext(Dispatchers.IO) {
+ try {
+ val granted = healthConnectClient.permissionController.getGrantedPermissions()
+ Log.d(TAG, "Total granted permissions: ${granted.size}")
+
+ // Check if we have at least the basic permissions (steps, calories, distance)
+ val basicPermissions = setOf(
+ HealthPermission.getReadPermission(StepsRecord::class),
+ HealthPermission.getReadPermission(TotalCaloriesBurnedRecord::class),
+ HealthPermission.getReadPermission(DistanceRecord::class)
+ )
+
+ val hasBasic = basicPermissions.all { it in granted }
+ Log.d(TAG, "Has basic permissions: $hasBasic")
+
+ basicPermissions.forEach { permission ->
+ val hasThis = permission in granted
+ Log.d(TAG, "Permission $permission: $hasThis")
+ }
+
+ hasBasic
+ } catch (e: Exception) {
+ Log.e(TAG, "Error checking permissions", e)
+ false
+ }
+ }
+
+ suspend fun hasAllPermissions(): Boolean = withContext(Dispatchers.IO) {
+ try {
+ val granted = healthConnectClient.permissionController.getGrantedPermissions()
+ val hasAll = PERMISSIONS.all { it in granted }
+ Log.d(TAG, "Has all permissions: $hasAll (${granted.size}/${PERMISSIONS.size})")
+
+ if (!hasAll) {
+ val missing = PERMISSIONS.filter { it !in granted }
+ Log.d(TAG, "Missing permissions: ${missing.size}")
+ missing.forEach { permission ->
+ Log.d(TAG, "Missing: $permission")
+ }
+ }
+
+ hasAll
+ } catch (e: Exception) {
+ Log.e(TAG, "Error checking permissions", e)
+ false
+ }
+ }
+
+ suspend fun getMissingPermissions(): Set = withContext(Dispatchers.IO) {
+ try {
+ val granted = healthConnectClient.permissionController.getGrantedPermissions()
+ PERMISSIONS.filter { it !in granted }.toSet()
+ } catch (e: Exception) {
+ Log.e(TAG, "Error getting missing permissions", e)
+ emptySet()
+ }
+ }
+
+ suspend fun getTodayStats(): HealthStats = withContext(Dispatchers.IO) {
+ getStatsForDate(java.time.LocalDate.now())
+ }
+
+ /**
+ * Get health stats for a specific date with caching
+ */
+ suspend fun getStatsForDate(
+ date: java.time.LocalDate,
+ forceRefresh: Boolean = false
+ ): HealthStats = withContext(Dispatchers.IO) {
+ try {
+ // Use system default timezone
+ val zoneId = ZoneId.systemDefault()
+ val startOfDay = date.atStartOfDay(zoneId)
+ val start = startOfDay.toInstant()
+
+ // For today, use current time; for past dates, use end of day
+ val end = if (date == java.time.LocalDate.now()) {
+ Instant.now()
+ } else {
+ startOfDay.plusDays(1).minusSeconds(1).toInstant()
+ }
+
+ val timestamp = start.toEpochMilli()
+
+ Log.d(TAG, "=== Health Data Query ===")
+ Log.d(TAG, "Date: $date")
+ Log.d(TAG, "Timezone: $zoneId")
+ Log.d(TAG, "Start: $start (${startOfDay})")
+ Log.d(TAG, "End: $end")
+ Log.d(TAG, "Is Today: ${date == java.time.LocalDate.now()}")
+
+ // Try cache first unless force refresh
+ if (forceRefresh) {
+ // Clear cache for this date when force refreshing
+ HealthDataCache.clearCacheForDate(context, timestamp)
+ Log.d(TAG, "Force refresh: cleared cache for $date")
+ } else {
+ val cached = HealthDataCache.loadSummary(context, timestamp)
+ if (cached != null) {
+ Log.d(TAG, "Using cached health data for $date")
+ Log.d(TAG, "Cached steps: ${cached.steps}, calories: ${cached.calories}")
+ return@withContext HealthStats(
+ steps = cached.steps?.toLong() ?: 0L,
+ calories = cached.calories?.toDouble() ?: 0.0,
+ distance = cached.distance ?: 0.0,
+ heartRate = cached.heartRateAvg?.toLong() ?: 0L,
+ heartRateMin = cached.heartRateMin?.toLong() ?: 0L,
+ heartRateMax = cached.heartRateMax?.toLong() ?: 0L,
+ sleepHours = (cached.sleepDuration ?: 0L) / 60.0,
+ activeMinutes = cached.activeMinutes?.toLong() ?: 0L,
+ floorsClimbed = cached.floorsClimbed?.toLong() ?: 0L,
+ weight = cached.weight ?: 0.0,
+ bloodPressureSystolic = cached.bloodPressureSystolic ?: 0,
+ bloodPressureDiastolic = cached.bloodPressureDiastolic ?: 0,
+ oxygenSaturation = cached.oxygenSaturation ?: 0.0,
+ restingHeartRate = cached.restingHeartRate?.toLong() ?: 0L,
+ vo2Max = cached.vo2Max ?: 0.0,
+ bodyTemperature = cached.bodyTemperature ?: 0.0,
+ bloodGlucose = cached.bloodGlucose ?: 0.0,
+ hydration = cached.hydration ?: 0.0
+ )
+ }
+ }
+
+ Log.d(TAG, "Fetching fresh health data for $date")
+
+ // Get heart rate stats
+ val heartRateStats = getHeartRateStats(start, end)
+ val bloodPressure = getBloodPressure(start, end)
+
+ // Check permissions before fetching
+ val grantedPermissions = healthConnectClient.permissionController.getGrantedPermissions()
+ Log.d(TAG, "Granted permissions count: ${grantedPermissions.size}")
+ grantedPermissions.forEach { perm ->
+ Log.d(TAG, " - $perm")
+ }
+
+ // Fetch each metric individually with logging
+ val steps = getSteps(start, end)
+ val calories = getCalories(start, end)
+ val distance = getDistance(start, end)
+ val sleep = getSleep(start, end)
+ val activeMinutes = getActiveMinutes(start, end)
+
+ Log.d(TAG, "=== Fetched Metrics ===")
+ Log.d(TAG, "Steps: $steps")
+ Log.d(TAG, "Calories: $calories")
+ Log.d(TAG, "Distance: $distance km")
+ Log.d(TAG, "Sleep: $sleep hours")
+ Log.d(TAG, "Active Minutes: $activeMinutes")
+ Log.d(TAG, "Heart Rate - Avg: ${heartRateStats.avg}, Min: ${heartRateStats.min}, Max: ${heartRateStats.max}")
+
+ val stats = HealthStats(
+ steps = steps,
+ calories = calories,
+ distance = distance,
+ heartRate = heartRateStats.avg,
+ heartRateMin = heartRateStats.min,
+ heartRateMax = heartRateStats.max,
+ sleepHours = sleep,
+ activeMinutes = activeMinutes,
+ floorsClimbed = getFloorsClimbed(start, end),
+ weight = getWeight(start, end),
+ bloodPressureSystolic = bloodPressure?.first ?: 0,
+ bloodPressureDiastolic = bloodPressure?.second ?: 0,
+ oxygenSaturation = getOxygenSaturation(start, end),
+ restingHeartRate = getRestingHeartRate(start, end),
+ vo2Max = getVo2Max(start, end),
+ bodyTemperature = getBodyTemperature(start, end),
+ bloodGlucose = getBloodGlucose(start, end),
+ hydration = getHydration(start, end)
+ )
+
+ Log.d(TAG, "Final stats created: $stats")
+
+ // Cache the result - preserve 0 values as valid data, only use null for truly missing data
+ val summary = com.sameerasw.airsync.models.HealthSummary(
+ date = timestamp,
+ steps = stats.steps.toInt(), // 0 steps is valid data
+ distance = if (stats.distance > 0) stats.distance else null,
+ calories = if (stats.calories > 0) stats.calories.toInt() else null,
+ activeMinutes = stats.activeMinutes.toInt(), // 0 active minutes is valid
+ heartRateAvg = if (stats.heartRate > 0) stats.heartRate.toInt() else null,
+ heartRateMin = if (stats.heartRateMin > 0) stats.heartRateMin.toInt() else null,
+ heartRateMax = if (stats.heartRateMax > 0) stats.heartRateMax.toInt() else null,
+ sleepDuration = if (stats.sleepHours > 0) (stats.sleepHours * 60).toLong() else null,
+ floorsClimbed = stats.floorsClimbed.toInt(), // 0 floors is valid
+ weight = if (stats.weight > 0) stats.weight else null,
+ bloodPressureSystolic = if (stats.bloodPressureSystolic > 0) stats.bloodPressureSystolic else null,
+ bloodPressureDiastolic = if (stats.bloodPressureDiastolic > 0) stats.bloodPressureDiastolic else null,
+ oxygenSaturation = if (stats.oxygenSaturation > 0) stats.oxygenSaturation else null,
+ restingHeartRate = if (stats.restingHeartRate > 0) stats.restingHeartRate.toInt() else null,
+ vo2Max = if (stats.vo2Max > 0) stats.vo2Max else null,
+ bodyTemperature = if (stats.bodyTemperature > 0) stats.bodyTemperature else null,
+ bloodGlucose = if (stats.bloodGlucose > 0) stats.bloodGlucose else null,
+ hydration = if (stats.hydration > 0) stats.hydration else null
+ )
+ HealthDataCache.saveSummary(context, summary)
+
+ stats
+ } catch (e: Exception) {
+ Log.e(TAG, "Error getting health stats for $date", e)
+ HealthStats()
+ }
+ }
+
+ private suspend fun getSteps(start: Instant, end: Instant): Long {
+ return try {
+ Log.d(TAG, "Querying steps from $start to $end")
+ val response = healthConnectClient.aggregate(
+ AggregateRequest(
+ metrics = setOf(StepsRecord.COUNT_TOTAL),
+ timeRangeFilter = TimeRangeFilter.between(start, end)
+ )
+ )
+ val steps = response[StepsRecord.COUNT_TOTAL] ?: 0L
+ Log.d(TAG, "Steps result: $steps")
+ steps
+ } catch (e: Exception) {
+ Log.e(TAG, "Error getting steps: ${e.message}", e)
+ 0L
+ }
+ }
+
+ private suspend fun getCalories(start: Instant, end: Instant): Double {
+ return try {
+ Log.d(TAG, "Querying calories from $start to $end")
+ val response = healthConnectClient.aggregate(
+ AggregateRequest(
+ metrics = setOf(TotalCaloriesBurnedRecord.ENERGY_TOTAL),
+ timeRangeFilter = TimeRangeFilter.between(start, end)
+ )
+ )
+ val calories = response[TotalCaloriesBurnedRecord.ENERGY_TOTAL]?.inKilocalories ?: 0.0
+ Log.d(TAG, "Calories result: $calories")
+ calories
+ } catch (e: Exception) {
+ Log.e(TAG, "Error getting calories: ${e.message}", e)
+ 0.0
+ }
+ }
+
+ private suspend fun getDistance(start: Instant, end: Instant): Double {
+ return try {
+ val response = healthConnectClient.aggregate(
+ AggregateRequest(
+ metrics = setOf(DistanceRecord.DISTANCE_TOTAL),
+ timeRangeFilter = TimeRangeFilter.between(start, end)
+ )
+ )
+ response[DistanceRecord.DISTANCE_TOTAL]?.inKilometers ?: 0.0
+ } catch (e: Exception) {
+ Log.e(TAG, "Error getting distance", e)
+ 0.0
+ }
+ }
+
+ private suspend fun getHeartRate(start: Instant, end: Instant): Long {
+ return try {
+ val response = healthConnectClient.aggregate(
+ AggregateRequest(
+ metrics = setOf(HeartRateRecord.BPM_AVG),
+ timeRangeFilter = TimeRangeFilter.between(start, end)
+ )
+ )
+ response[HeartRateRecord.BPM_AVG] ?: 0L
+ } catch (e: Exception) {
+ Log.e(TAG, "Error getting heart rate", e)
+ 0L
+ }
+ }
+
+ private suspend fun getSleep(start: Instant, end: Instant): Double {
+ return try {
+ val response = healthConnectClient.aggregate(
+ AggregateRequest(
+ metrics = setOf(SleepSessionRecord.SLEEP_DURATION_TOTAL),
+ timeRangeFilter = TimeRangeFilter.between(start, end)
+ )
+ )
+ val duration = response[SleepSessionRecord.SLEEP_DURATION_TOTAL]
+ duration?.toHours()?.toDouble() ?: 0.0
+ } catch (e: Exception) {
+ Log.e(TAG, "Error getting sleep", e)
+ 0.0
+ }
+ }
+
+ private suspend fun getActiveMinutes(start: Instant, end: Instant): Long {
+ return try {
+ val response = healthConnectClient.aggregate(
+ AggregateRequest(
+ metrics = setOf(ExerciseSessionRecord.EXERCISE_DURATION_TOTAL),
+ timeRangeFilter = TimeRangeFilter.between(start, end)
+ )
+ )
+ val duration = response[ExerciseSessionRecord.EXERCISE_DURATION_TOTAL]
+ duration?.toMinutes() ?: 0L
+ } catch (e: Exception) {
+ Log.e(TAG, "Error getting active minutes", e)
+ 0L
+ }
+ }
+
+ private data class HeartRateStats(val min: Long, val max: Long, val avg: Long)
+
+ private suspend fun getHeartRateStats(start: Instant, end: Instant): HeartRateStats {
+ return try {
+ val response = healthConnectClient.readRecords(
+ androidx.health.connect.client.request.ReadRecordsRequest(
+ HeartRateRecord::class,
+ timeRangeFilter = TimeRangeFilter.between(start, end)
+ )
+ )
+ val allSamples = response.records.flatMap { it.samples }
+ if (allSamples.isEmpty()) {
+ return HeartRateStats(0, 0, 0)
+ }
+ val bpmValues = allSamples.map { it.beatsPerMinute }
+ HeartRateStats(
+ min = bpmValues.minOrNull() ?: 0,
+ max = bpmValues.maxOrNull() ?: 0,
+ avg = bpmValues.average().toLong()
+ )
+ } catch (e: Exception) {
+ Log.e(TAG, "Error getting heart rate stats", e)
+ HeartRateStats(0, 0, 0)
+ }
+ }
+
+ private suspend fun getFloorsClimbed(start: Instant, end: Instant): Long {
+ return try {
+ val response = healthConnectClient.aggregate(
+ AggregateRequest(
+ metrics = setOf(FloorsClimbedRecord.FLOORS_CLIMBED_TOTAL),
+ timeRangeFilter = TimeRangeFilter.between(start, end)
+ )
+ )
+ response[FloorsClimbedRecord.FLOORS_CLIMBED_TOTAL]?.toLong() ?: 0L
+ } catch (e: Exception) {
+ Log.e(TAG, "Error getting floors climbed", e)
+ 0L
+ }
+ }
+
+ private suspend fun getWeight(start: Instant, end: Instant): Double {
+ return try {
+ val response = healthConnectClient.readRecords(
+ androidx.health.connect.client.request.ReadRecordsRequest(
+ WeightRecord::class,
+ timeRangeFilter = TimeRangeFilter.between(start, end)
+ )
+ )
+ response.records.lastOrNull()?.weight?.inKilograms ?: 0.0
+ } catch (e: Exception) {
+ Log.e(TAG, "Error getting weight", e)
+ 0.0
+ }
+ }
+
+ private suspend fun getBloodPressure(start: Instant, end: Instant): Pair? {
+ return try {
+ val response = healthConnectClient.readRecords(
+ androidx.health.connect.client.request.ReadRecordsRequest(
+ BloodPressureRecord::class,
+ timeRangeFilter = TimeRangeFilter.between(start, end)
+ )
+ )
+ val latest = response.records.lastOrNull()
+ if (latest != null) {
+ Pair(
+ latest.systolic.inMillimetersOfMercury.toInt(),
+ latest.diastolic.inMillimetersOfMercury.toInt()
+ )
+ } else null
+ } catch (e: Exception) {
+ Log.e(TAG, "Error getting blood pressure", e)
+ null
+ }
+ }
+
+ private suspend fun getOxygenSaturation(start: Instant, end: Instant): Double {
+ return try {
+ val response = healthConnectClient.readRecords(
+ androidx.health.connect.client.request.ReadRecordsRequest(
+ OxygenSaturationRecord::class,
+ timeRangeFilter = TimeRangeFilter.between(start, end)
+ )
+ )
+ response.records.lastOrNull()?.percentage?.value ?: 0.0
+ } catch (e: Exception) {
+ Log.e(TAG, "Error getting oxygen saturation", e)
+ 0.0
+ }
+ }
+
+ private suspend fun getRestingHeartRate(start: Instant, end: Instant): Long {
+ return try {
+ val response = healthConnectClient.readRecords(
+ androidx.health.connect.client.request.ReadRecordsRequest(
+ RestingHeartRateRecord::class,
+ timeRangeFilter = TimeRangeFilter.between(start, end)
+ )
+ )
+ response.records.lastOrNull()?.beatsPerMinute ?: 0L
+ } catch (e: Exception) {
+ Log.e(TAG, "Error getting resting heart rate", e)
+ 0L
+ }
+ }
+
+ private suspend fun getVo2Max(start: Instant, end: Instant): Double {
+ return try {
+ val response = healthConnectClient.readRecords(
+ androidx.health.connect.client.request.ReadRecordsRequest(
+ Vo2MaxRecord::class,
+ timeRangeFilter = TimeRangeFilter.between(start, end)
+ )
+ )
+ response.records.lastOrNull()?.vo2MillilitersPerMinuteKilogram ?: 0.0
+ } catch (e: Exception) {
+ Log.e(TAG, "Error getting VO2 max", e)
+ 0.0
+ }
+ }
+
+ private suspend fun getBodyTemperature(start: Instant, end: Instant): Double {
+ return try {
+ val response = healthConnectClient.readRecords(
+ androidx.health.connect.client.request.ReadRecordsRequest(
+ BodyTemperatureRecord::class,
+ timeRangeFilter = TimeRangeFilter.between(start, end)
+ )
+ )
+ response.records.lastOrNull()?.temperature?.inCelsius ?: 0.0
+ } catch (e: Exception) {
+ Log.e(TAG, "Error getting body temperature", e)
+ 0.0
+ }
+ }
+
+ private suspend fun getBloodGlucose(start: Instant, end: Instant): Double {
+ return try {
+ val response = healthConnectClient.readRecords(
+ androidx.health.connect.client.request.ReadRecordsRequest(
+ BloodGlucoseRecord::class,
+ timeRangeFilter = TimeRangeFilter.between(start, end)
+ )
+ )
+ response.records.lastOrNull()?.level?.inMillimolesPerLiter ?: 0.0
+ } catch (e: Exception) {
+ Log.e(TAG, "Error getting blood glucose", e)
+ 0.0
+ }
+ }
+
+ private suspend fun getHydration(start: Instant, end: Instant): Double {
+ return try {
+ val response = healthConnectClient.aggregate(
+ AggregateRequest(
+ metrics = setOf(HydrationRecord.VOLUME_TOTAL),
+ timeRangeFilter = TimeRangeFilter.between(start, end)
+ )
+ )
+ response[HydrationRecord.VOLUME_TOTAL]?.inLiters ?: 0.0
+ } catch (e: Exception) {
+ Log.e(TAG, "Error getting hydration", e)
+ 0.0
+ }
+ }
+}
diff --git a/app/src/main/java/com/sameerasw/airsync/health/SimpleHealthScreen.kt b/app/src/main/java/com/sameerasw/airsync/health/SimpleHealthScreen.kt
new file mode 100644
index 0000000..32dd2a8
--- /dev/null
+++ b/app/src/main/java/com/sameerasw/airsync/health/SimpleHealthScreen.kt
@@ -0,0 +1,717 @@
+package com.sameerasw.airsync.health
+
+import android.content.Intent
+import android.net.Uri
+import androidx.activity.compose.rememberLauncherForActivityResult
+import androidx.compose.foundation.layout.*
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Air
+import androidx.compose.material.icons.filled.ArrowBack
+import androidx.compose.material.icons.filled.Bedtime
+import androidx.compose.material.icons.filled.Bloodtype
+import androidx.compose.material.icons.filled.CalendarToday
+import androidx.compose.material.icons.filled.ChevronLeft
+import androidx.compose.material.icons.filled.ChevronRight
+import androidx.compose.material.icons.filled.DirectionsRun
+import androidx.compose.material.icons.filled.Download
+import androidx.compose.material.icons.filled.Favorite
+import androidx.compose.material.icons.filled.FavoriteBorder
+import androidx.compose.material.icons.filled.FitnessCenter
+import androidx.compose.material.icons.filled.MonitorHeart
+import androidx.compose.material.icons.filled.Refresh
+import androidx.compose.material.icons.filled.Scale
+import androidx.compose.material.icons.filled.Security
+import androidx.compose.material.icons.filled.Stairs
+import androidx.compose.material.icons.filled.Thermostat
+import androidx.compose.material.icons.filled.Timer
+import androidx.compose.material.icons.filled.Warning
+import androidx.compose.material.icons.filled.WaterDrop
+import androidx.compose.material3.*
+import androidx.compose.runtime.*
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.dp
+import androidx.health.connect.client.PermissionController
+import kotlinx.coroutines.launch
+import java.time.format.DateTimeFormatter
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun SimpleHealthScreen(
+ onNavigateBack: () -> Unit
+) {
+ val context = LocalContext.current
+ val scope = rememberCoroutineScope()
+ val healthManager = remember { SimpleHealthConnectManager(context) }
+
+ var isAvailable by remember { mutableStateOf(false) }
+ var hasPermissions by remember { mutableStateOf(false) }
+ var healthStats by remember { mutableStateOf(HealthStats()) }
+ var isLoading by remember { mutableStateOf(true) }
+ var errorMessage by remember { mutableStateOf(null) }
+ var selectedDate by remember { mutableStateOf(java.time.LocalDate.now()) }
+ var showDatePicker by remember { mutableStateOf(false) }
+
+ val permissionLauncher = rememberLauncherForActivityResult(
+ contract = PermissionController.createRequestPermissionResultContract()
+ ) { granted: Set ->
+ scope.launch {
+ hasPermissions = healthManager.hasPermissions()
+ if (hasPermissions) {
+ isLoading = true
+ healthStats = healthManager.getStatsForDate(selectedDate)
+ isLoading = false
+ }
+ }
+ }
+
+ // Load data when date changes
+ LaunchedEffect(selectedDate) {
+ if (isAvailable && hasPermissions) {
+ android.util.Log.d("HealthScreen", "Date changed to: $selectedDate, loading data...")
+ isLoading = true
+ healthStats = healthManager.getStatsForDate(selectedDate)
+ android.util.Log.d("HealthScreen", "Date change - Loaded stats: steps=${healthStats.steps}, calories=${healthStats.calories}")
+ isLoading = false
+ }
+ }
+
+ LaunchedEffect(Unit) {
+ isAvailable = healthManager.isAvailable()
+ android.util.Log.d("HealthScreen", "Health Connect available: $isAvailable")
+
+ if (isAvailable) {
+ hasPermissions = healthManager.hasPermissions()
+ android.util.Log.d("HealthScreen", "Has basic permissions: $hasPermissions")
+
+ val allPermissions = healthManager.hasAllPermissions()
+ android.util.Log.d("HealthScreen", "Has all permissions: $allPermissions")
+
+ if (hasPermissions) {
+ android.util.Log.d("HealthScreen", "Loading health stats for date: $selectedDate")
+ healthStats = healthManager.getStatsForDate(selectedDate)
+ android.util.Log.d("HealthScreen", "Loaded health stats: steps=${healthStats.steps}, calories=${healthStats.calories}, heartRate=${healthStats.heartRate}")
+ }
+ }
+ isLoading = false
+
+ // Clean old cache on startup
+ HealthDataCache.cleanOldCache(context)
+ }
+
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .verticalScroll(rememberScrollState())
+ .padding(horizontal = 16.dp)
+ .padding(top = 16.dp, bottom = 16.dp),
+ verticalArrangement = Arrangement.spacedBy(16.dp)
+ ) {
+ // Header with actions
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Text(
+ text = "Health & Fitness",
+ style = MaterialTheme.typography.headlineMedium,
+ fontWeight = FontWeight.Bold
+ )
+
+ if (hasPermissions) {
+ Row {
+ IconButton(
+ onClick = { showDatePicker = true }
+ ) {
+ Icon(Icons.Default.CalendarToday, "Select Date")
+ }
+ IconButton(
+ onClick = {
+ scope.launch {
+ android.util.Log.d("HealthScreen", "Manual refresh triggered")
+ isLoading = true
+ healthStats = healthManager.getStatsForDate(selectedDate, forceRefresh = true)
+ android.util.Log.d("HealthScreen", "Manual refresh completed: steps=${healthStats.steps}")
+ isLoading = false
+ }
+ }
+ ) {
+ Icon(Icons.Default.Refresh, "Refresh")
+ }
+ }
+ }
+ }
+
+ when {
+ !isAvailable -> {
+ HealthConnectNotAvailableCard()
+ }
+ !hasPermissions -> {
+ PermissionsNeededCard(
+ onRequestPermissions = {
+ permissionLauncher.launch(SimpleHealthConnectManager.PERMISSIONS)
+ }
+ )
+ }
+ else -> {
+ // Check if all permissions are granted
+ var hasAllPermissions by remember { mutableStateOf(true) }
+
+ LaunchedEffect(Unit) {
+ hasAllPermissions = healthManager.hasAllPermissions()
+ }
+
+ // Show info card if not all permissions granted
+ if (!hasAllPermissions) {
+ Card(
+ modifier = Modifier.fillMaxWidth(),
+ colors = CardDefaults.cardColors(
+ containerColor = MaterialTheme.colorScheme.secondaryContainer
+ )
+ ) {
+ Column(
+ modifier = Modifier.padding(16.dp),
+ verticalArrangement = Arrangement.spacedBy(8.dp)
+ ) {
+ Text(
+ "Additional Metrics Available",
+ style = MaterialTheme.typography.titleSmall,
+ fontWeight = FontWeight.Bold
+ )
+ Text(
+ "Grant additional permissions to see more health metrics like blood pressure, oxygen saturation, and body temperature.",
+ style = MaterialTheme.typography.bodySmall
+ )
+ TextButton(
+ onClick = {
+ permissionLauncher.launch(SimpleHealthConnectManager.PERMISSIONS)
+ }
+ ) {
+ Text("Grant More Permissions")
+ }
+ }
+ }
+ }
+
+ // Date navigation card
+ DateNavigationCard(
+ selectedDate = selectedDate,
+ onPreviousDay = {
+ selectedDate = selectedDate.minusDays(1)
+ },
+ onNextDay = {
+ if (selectedDate < java.time.LocalDate.now()) {
+ selectedDate = selectedDate.plusDays(1)
+ }
+ },
+ onToday = {
+ selectedDate = java.time.LocalDate.now()
+ }
+ )
+
+ if (isLoading) {
+ Box(
+ modifier = Modifier.fillMaxWidth().padding(32.dp),
+ contentAlignment = Alignment.Center
+ ) {
+ CircularProgressIndicator()
+ }
+ } else {
+ HealthStatsCards(healthStats, selectedDate)
+ }
+ }
+ }
+ }
+
+ // Date picker dialog (outside the scrollable column)
+ if (showDatePicker) {
+ DatePickerDialog(
+ selectedDate = selectedDate,
+ onDateSelected = { date ->
+ selectedDate = date
+ showDatePicker = false
+ },
+ onDismiss = { showDatePicker = false }
+ )
+ }
+}
+
+@Composable
+private fun HealthConnectNotAvailableCard() {
+ val context = LocalContext.current
+
+ Card(
+ modifier = Modifier.fillMaxWidth(),
+ colors = CardDefaults.cardColors(
+ containerColor = MaterialTheme.colorScheme.errorContainer
+ )
+ ) {
+ Column(
+ modifier = Modifier.padding(16.dp),
+ verticalArrangement = Arrangement.spacedBy(12.dp)
+ ) {
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(8.dp)
+ ) {
+ Icon(
+ Icons.Default.Warning,
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.error
+ )
+ Text(
+ "Health Connect Not Available",
+ style = MaterialTheme.typography.titleMedium,
+ fontWeight = FontWeight.Bold
+ )
+ }
+
+ Text(
+ "Health Connect is required to access health data from Google Fit, Samsung Health, and other apps.",
+ style = MaterialTheme.typography.bodyMedium
+ )
+
+ Button(
+ onClick = {
+ val intent = Intent(Intent.ACTION_VIEW).apply {
+ data = Uri.parse("market://details?id=com.google.android.apps.healthdata")
+ }
+ context.startActivity(intent)
+ },
+ modifier = Modifier.fillMaxWidth()
+ ) {
+ Icon(Icons.Default.Download, contentDescription = null)
+ Spacer(modifier = Modifier.width(8.dp))
+ Text("Install Health Connect")
+ }
+ }
+ }
+}
+
+@Composable
+private fun PermissionsNeededCard(onRequestPermissions: () -> Unit) {
+ Card(
+ modifier = Modifier.fillMaxWidth(),
+ colors = CardDefaults.cardColors(
+ containerColor = MaterialTheme.colorScheme.secondaryContainer
+ )
+ ) {
+ Column(
+ modifier = Modifier.padding(16.dp),
+ verticalArrangement = Arrangement.spacedBy(12.dp)
+ ) {
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(8.dp)
+ ) {
+ Icon(
+ Icons.Default.Security,
+ contentDescription = null
+ )
+ Text(
+ "Permissions Needed",
+ style = MaterialTheme.typography.titleMedium,
+ fontWeight = FontWeight.Bold
+ )
+ }
+
+ Text(
+ "Grant permissions to access your health data from Health Connect.",
+ style = MaterialTheme.typography.bodyMedium
+ )
+
+ Button(
+ onClick = onRequestPermissions,
+ modifier = Modifier.fillMaxWidth()
+ ) {
+ Icon(Icons.Default.Security, contentDescription = null)
+ Spacer(modifier = Modifier.width(8.dp))
+ Text("Grant Permissions")
+ }
+ }
+ }
+}
+
+@Composable
+private fun DateNavigationCard(
+ selectedDate: java.time.LocalDate,
+ onPreviousDay: () -> Unit,
+ onNextDay: () -> Unit,
+ onToday: () -> Unit
+) {
+ Card(modifier = Modifier.fillMaxWidth()) {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(12.dp),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ IconButton(onClick = onPreviousDay) {
+ Icon(Icons.Default.ChevronLeft, "Previous Day")
+ }
+
+ Column(horizontalAlignment = Alignment.CenterHorizontally) {
+ Text(
+ text = when {
+ selectedDate == java.time.LocalDate.now() -> "Today"
+ selectedDate == java.time.LocalDate.now().minusDays(1) -> "Yesterday"
+ else -> selectedDate.format(java.time.format.DateTimeFormatter.ofPattern("MMM dd, yyyy"))
+ },
+ style = MaterialTheme.typography.titleMedium,
+ fontWeight = FontWeight.Bold
+ )
+ if (selectedDate != java.time.LocalDate.now()) {
+ TextButton(onClick = onToday) {
+ Text("Go to Today")
+ }
+ }
+ }
+
+ IconButton(
+ onClick = onNextDay,
+ enabled = selectedDate < java.time.LocalDate.now()
+ ) {
+ Icon(Icons.Default.ChevronRight, "Next Day")
+ }
+ }
+ }
+}
+
+@Composable
+private fun DatePickerDialog(
+ selectedDate: java.time.LocalDate,
+ onDateSelected: (java.time.LocalDate) -> Unit,
+ onDismiss: () -> Unit
+) {
+ val datePickerState = rememberDatePickerState(
+ initialSelectedDateMillis = selectedDate.atStartOfDay(java.time.ZoneId.systemDefault()).toInstant().toEpochMilli()
+ )
+
+ androidx.compose.material3.DatePickerDialog(
+ onDismissRequest = onDismiss,
+ confirmButton = {
+ TextButton(
+ onClick = {
+ datePickerState.selectedDateMillis?.let { millis ->
+ val date = java.time.Instant.ofEpochMilli(millis)
+ .atZone(java.time.ZoneId.systemDefault())
+ .toLocalDate()
+ onDateSelected(date)
+ }
+ }
+ ) {
+ Text("OK")
+ }
+ },
+ dismissButton = {
+ TextButton(onClick = onDismiss) {
+ Text("Cancel")
+ }
+ }
+ ) {
+ DatePicker(state = datePickerState)
+ }
+}
+
+@Composable
+private fun HealthStatsCards(stats: HealthStats, selectedDate: java.time.LocalDate) {
+ // Summary Card
+ Card(
+ modifier = Modifier.fillMaxWidth(),
+ colors = CardDefaults.cardColors(
+ containerColor = MaterialTheme.colorScheme.primaryContainer
+ )
+ ) {
+ Column(
+ modifier = Modifier.padding(16.dp),
+ verticalArrangement = Arrangement.spacedBy(8.dp)
+ ) {
+ Text(
+ when {
+ selectedDate == java.time.LocalDate.now() -> "Today's Summary"
+ selectedDate == java.time.LocalDate.now().minusDays(1) -> "Yesterday's Summary"
+ else -> "Summary for ${selectedDate.format(java.time.format.DateTimeFormatter.ofPattern("MMM dd"))}"
+ },
+ style = MaterialTheme.typography.titleLarge,
+ fontWeight = FontWeight.Bold
+ )
+
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.SpaceEvenly
+ ) {
+ StatItem(
+ icon = "👣",
+ value = if (stats.steps > 0) stats.steps.toString() else "--",
+ label = "Steps"
+ )
+ StatItem(
+ icon = "🔥",
+ value = if (stats.calories > 0) String.format("%.0f", stats.calories) else "--",
+ label = "Calories"
+ )
+ StatItem(
+ icon = "❤️",
+ value = if (stats.heartRate > 0) stats.heartRate.toString() else "--",
+ label = "Heart Rate"
+ )
+ }
+ }
+ }
+
+ // Activity Card
+ Card(modifier = Modifier.fillMaxWidth()) {
+ Column(
+ modifier = Modifier.padding(16.dp),
+ verticalArrangement = Arrangement.spacedBy(12.dp)
+ ) {
+ Text(
+ "Activity",
+ style = MaterialTheme.typography.titleMedium,
+ fontWeight = FontWeight.Bold
+ )
+
+ if (stats.distance > 0) {
+ StatRow(
+ icon = Icons.Default.DirectionsRun,
+ label = "Distance",
+ value = String.format("%.2f km", stats.distance)
+ )
+ }
+
+ if (stats.activeMinutes > 0) {
+ StatRow(
+ icon = Icons.Default.Timer,
+ label = "Active Minutes",
+ value = "${stats.activeMinutes} min"
+ )
+ }
+
+ if (stats.distance == 0.0 && stats.activeMinutes == 0L) {
+ Text(
+ "No activity data available",
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
+ }
+ }
+
+ // Sleep & Lifestyle Card
+ Card(modifier = Modifier.fillMaxWidth()) {
+ Column(
+ modifier = Modifier.padding(16.dp),
+ verticalArrangement = Arrangement.spacedBy(12.dp)
+ ) {
+ Text(
+ "Sleep & Lifestyle",
+ style = MaterialTheme.typography.titleMedium,
+ fontWeight = FontWeight.Bold
+ )
+
+ if (stats.sleepHours > 0) {
+ StatRow(
+ icon = Icons.Default.Bedtime,
+ label = "Sleep",
+ value = String.format("%.1f hours", stats.sleepHours)
+ )
+ }
+
+ if (stats.floorsClimbed > 0) {
+ StatRow(
+ icon = Icons.Default.Stairs,
+ label = "Floors Climbed",
+ value = "${stats.floorsClimbed}"
+ )
+ }
+
+ if (stats.hydration > 0) {
+ StatRow(
+ icon = Icons.Default.WaterDrop,
+ label = "Hydration",
+ value = String.format("%.2f L", stats.hydration)
+ )
+ }
+
+ if (stats.sleepHours == 0.0 && stats.floorsClimbed == 0L && stats.hydration == 0.0) {
+ Text(
+ "No sleep or lifestyle data available",
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
+ }
+ }
+
+ // Vitals Card
+ Card(modifier = Modifier.fillMaxWidth()) {
+ Column(
+ modifier = Modifier.padding(16.dp),
+ verticalArrangement = Arrangement.spacedBy(12.dp)
+ ) {
+ Text(
+ "Vitals",
+ style = MaterialTheme.typography.titleMedium,
+ fontWeight = FontWeight.Bold
+ )
+
+ if (stats.heartRateMin > 0 && stats.heartRateMax > 0) {
+ StatRow(
+ icon = Icons.Default.Favorite,
+ label = "Heart Rate Range",
+ value = "${stats.heartRateMin} - ${stats.heartRateMax} bpm"
+ )
+ }
+
+ if (stats.restingHeartRate > 0) {
+ StatRow(
+ icon = Icons.Default.FavoriteBorder,
+ label = "Resting Heart Rate",
+ value = "${stats.restingHeartRate} bpm"
+ )
+ }
+
+ if (stats.bloodPressureSystolic > 0 && stats.bloodPressureDiastolic > 0) {
+ StatRow(
+ icon = Icons.Default.MonitorHeart,
+ label = "Blood Pressure",
+ value = "${stats.bloodPressureSystolic}/${stats.bloodPressureDiastolic} mmHg"
+ )
+ }
+
+ if (stats.oxygenSaturation > 0) {
+ StatRow(
+ icon = Icons.Default.Air,
+ label = "Blood Oxygen",
+ value = String.format("%.1f%%", stats.oxygenSaturation)
+ )
+ }
+
+ if (stats.heartRateMin == 0L && stats.heartRateMax == 0L && stats.restingHeartRate == 0L &&
+ stats.bloodPressureSystolic == 0 && stats.oxygenSaturation == 0.0) {
+ Text(
+ "No vitals data available",
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
+ }
+ }
+
+ // Body Metrics Card
+ Card(modifier = Modifier.fillMaxWidth()) {
+ Column(
+ modifier = Modifier.padding(16.dp),
+ verticalArrangement = Arrangement.spacedBy(12.dp)
+ ) {
+ Text(
+ "Body Metrics",
+ style = MaterialTheme.typography.titleMedium,
+ fontWeight = FontWeight.Bold
+ )
+
+ if (stats.weight > 0) {
+ StatRow(
+ icon = Icons.Default.Scale,
+ label = "Weight",
+ value = String.format("%.1f kg", stats.weight)
+ )
+ }
+
+ if (stats.bodyTemperature > 0) {
+ StatRow(
+ icon = Icons.Default.Thermostat,
+ label = "Body Temperature",
+ value = String.format("%.1f°C", stats.bodyTemperature)
+ )
+ }
+
+ if (stats.bloodGlucose > 0) {
+ StatRow(
+ icon = Icons.Default.Bloodtype,
+ label = "Blood Glucose",
+ value = String.format("%.1f mmol/L", stats.bloodGlucose)
+ )
+ }
+
+ if (stats.vo2Max > 0) {
+ StatRow(
+ icon = Icons.Default.FitnessCenter,
+ label = "VO2 Max",
+ value = String.format("%.1f mL/kg/min", stats.vo2Max)
+ )
+ }
+
+ if (stats.weight == 0.0 && stats.bodyTemperature == 0.0 &&
+ stats.bloodGlucose == 0.0 && stats.vo2Max == 0.0) {
+ Text(
+ "No body metrics data available",
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
+ }
+ }
+
+ // Add bottom padding for scrolling (extra space for navigation bar)
+ Spacer(modifier = Modifier.height(100.dp))
+}
+
+@Composable
+private fun StatItem(
+ icon: String,
+ value: String,
+ label: String
+) {
+ Column(
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.spacedBy(4.dp)
+ ) {
+ Text(
+ icon,
+ style = MaterialTheme.typography.headlineMedium
+ )
+ Text(
+ value,
+ style = MaterialTheme.typography.titleLarge,
+ fontWeight = FontWeight.Bold
+ )
+ Text(
+ label,
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
+}
+
+@Composable
+private fun StatRow(
+ icon: androidx.compose.ui.graphics.vector.ImageVector,
+ label: String,
+ value: String
+) {
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Row(
+ horizontalArrangement = Arrangement.spacedBy(12.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Icon(icon, contentDescription = null)
+ Text(label, style = MaterialTheme.typography.bodyLarge)
+ }
+ Text(
+ value,
+ style = MaterialTheme.typography.titleMedium,
+ fontWeight = FontWeight.Bold
+ )
+ }
+}
diff --git a/app/src/main/java/com/sameerasw/airsync/models/CallLog.kt b/app/src/main/java/com/sameerasw/airsync/models/CallLog.kt
new file mode 100644
index 0000000..050e209
--- /dev/null
+++ b/app/src/main/java/com/sameerasw/airsync/models/CallLog.kt
@@ -0,0 +1,27 @@
+package com.sameerasw.airsync.models
+
+data class CallLogEntry(
+ val id: String,
+ val number: String,
+ val contactName: String?,
+ val type: Int, // 1 = incoming, 2 = outgoing, 3 = missed
+ val date: Long,
+ val duration: Long, // in seconds
+ val isRead: Boolean
+)
+
+data class OngoingCall(
+ val id: String,
+ val number: String,
+ val contactName: String?,
+ val state: CallState,
+ val startTime: Long,
+ val isIncoming: Boolean
+)
+
+enum class CallState {
+ RINGING,
+ ACTIVE,
+ HELD,
+ DISCONNECTED
+}
diff --git a/app/src/main/java/com/sameerasw/airsync/models/ControlEvent.kt b/app/src/main/java/com/sameerasw/airsync/models/ControlEvent.kt
new file mode 100644
index 0000000..554f6b1
--- /dev/null
+++ b/app/src/main/java/com/sameerasw/airsync/models/ControlEvent.kt
@@ -0,0 +1,8 @@
+package com.sameerasw.airsync.models
+
+data class ControlEvent(
+ val type: String,
+ val x: Float? = null,
+ val y: Float? = null,
+ val keyCode: Int? = null
+)
diff --git a/app/src/main/java/com/sameerasw/airsync/models/HealthData.kt b/app/src/main/java/com/sameerasw/airsync/models/HealthData.kt
new file mode 100644
index 0000000..2d61a84
--- /dev/null
+++ b/app/src/main/java/com/sameerasw/airsync/models/HealthData.kt
@@ -0,0 +1,45 @@
+package com.sameerasw.airsync.models
+
+data class HealthData(
+ val timestamp: Long,
+ val dataType: HealthDataType,
+ val value: Double,
+ val unit: String,
+ val source: String
+)
+
+enum class HealthDataType {
+ STEPS,
+ HEART_RATE,
+ DISTANCE,
+ CALORIES,
+ SLEEP,
+ BLOOD_PRESSURE,
+ BLOOD_OXYGEN,
+ WEIGHT,
+ ACTIVE_MINUTES,
+ FLOORS_CLIMBED
+}
+
+data class HealthSummary(
+ val date: Long,
+ val steps: Int?,
+ val distance: Double?, // km
+ val calories: Int?,
+ val activeMinutes: Int?,
+ val heartRateAvg: Int?,
+ val heartRateMin: Int?,
+ val heartRateMax: Int?,
+ val sleepDuration: Long?, // in minutes
+ // Additional fields
+ val floorsClimbed: Int?,
+ val weight: Double?, // kg
+ val bloodPressureSystolic: Int?,
+ val bloodPressureDiastolic: Int?,
+ val oxygenSaturation: Double?, // percentage
+ val restingHeartRate: Int?,
+ val vo2Max: Double?,
+ val bodyTemperature: Double?, // celsius
+ val bloodGlucose: Double?, // mmol/L
+ val hydration: Double? // liters
+)
diff --git a/app/src/main/java/com/sameerasw/airsync/models/MirrorResponse.kt b/app/src/main/java/com/sameerasw/airsync/models/MirrorResponse.kt
new file mode 100644
index 0000000..f8c3c9f
--- /dev/null
+++ b/app/src/main/java/com/sameerasw/airsync/models/MirrorResponse.kt
@@ -0,0 +1,18 @@
+package com.sameerasw.airsync.models
+
+import com.google.gson.annotations.SerializedName
+
+data class MirrorResponse(
+ @SerializedName("type") val type: String,
+ @SerializedName("data") val data: MirrorResponseData
+)
+
+data class MirrorResponseData(
+ @SerializedName("status") val status: String,
+ @SerializedName("mode") val mode: String?,
+ @SerializedName("package") val pkg: String?,
+ @SerializedName("transport") val transport: String?,
+ @SerializedName("wsUrl") val wsUrl: String?,
+ @SerializedName("message") val message: String?,
+ @SerializedName("ok") val ok: Boolean = false // For simple acks
+)
diff --git a/app/src/main/java/com/sameerasw/airsync/models/MirroringMessages.kt b/app/src/main/java/com/sameerasw/airsync/models/MirroringMessages.kt
new file mode 100644
index 0000000..4540ff2
--- /dev/null
+++ b/app/src/main/java/com/sameerasw/airsync/models/MirroringMessages.kt
@@ -0,0 +1,33 @@
+
+package com.sameerasw.airsync.models
+
+import com.google.gson.annotations.SerializedName
+
+data class MirrorStartRequest(
+ @SerializedName("type") val type: String = "mirrorStart",
+ @SerializedName("data") val data: MirrorStartData
+)
+
+data class MirrorStartData(
+ @SerializedName("fps") val fps: Int? = null,
+ @SerializedName("quality") val quality: Float? = null,
+ @SerializedName("width") val width: Int? = null,
+ @SerializedName("height") val height: Int? = null
+)
+
+data class MirrorFrame(
+ @SerializedName("type") val type: String = "mirrorFrame",
+ @SerializedName("data") val data: MirrorFrameData
+)
+
+data class MirrorFrameData(
+ @SerializedName("image") val image: String,
+ @SerializedName("format") val format: String,
+ @SerializedName("ts") val timestamp: Long? = null,
+ @SerializedName("seq") val sequence: Int? = null
+)
+
+data class MirrorStopRequest(
+ @SerializedName("type") val type: String = "mirrorStop",
+ @SerializedName("data") val data: Map = emptyMap()
+)
diff --git a/app/src/main/java/com/sameerasw/airsync/models/PermissionInfo.kt b/app/src/main/java/com/sameerasw/airsync/models/PermissionInfo.kt
new file mode 100644
index 0000000..43d5f92
--- /dev/null
+++ b/app/src/main/java/com/sameerasw/airsync/models/PermissionInfo.kt
@@ -0,0 +1,28 @@
+package com.sameerasw.airsync.models
+
+data class PermissionInfo(
+ val permission: String,
+ val displayName: String,
+ val description: String,
+ val category: PermissionCategory,
+ val isGranted: Boolean,
+ val isRequired: Boolean = false,
+ val requiresSpecialHandling: Boolean = false
+)
+
+enum class PermissionCategory {
+ CORE, // Essential for app to work
+ MESSAGING, // SMS and messaging features
+ CALLS, // Call logs and phone state
+ HEALTH, // Health Connect permissions
+ LOCATION, // Location for activity tracking
+ STORAGE, // File access
+ SPECIAL // Special permissions (accessibility, notification listener, etc.)
+}
+
+data class PermissionGroup(
+ val category: PermissionCategory,
+ val title: String,
+ val description: String,
+ val permissions: List
+)
diff --git a/app/src/main/java/com/sameerasw/airsync/models/SmsMessage.kt b/app/src/main/java/com/sameerasw/airsync/models/SmsMessage.kt
new file mode 100644
index 0000000..afd8913
--- /dev/null
+++ b/app/src/main/java/com/sameerasw/airsync/models/SmsMessage.kt
@@ -0,0 +1,22 @@
+package com.sameerasw.airsync.models
+
+data class SmsMessage(
+ val id: String,
+ val threadId: String,
+ val address: String,
+ val body: String,
+ val date: Long,
+ val type: Int, // 1 = received, 2 = sent
+ val read: Boolean,
+ val contactName: String? = null
+)
+
+data class SmsThread(
+ val threadId: String,
+ val address: String,
+ val contactName: String?,
+ val messageCount: Int,
+ val snippet: String,
+ val date: Long,
+ val unreadCount: Int
+)
diff --git a/app/src/main/java/com/sameerasw/airsync/presentation/ui/activities/AutoApproveMirrorActivity.kt b/app/src/main/java/com/sameerasw/airsync/presentation/ui/activities/AutoApproveMirrorActivity.kt
new file mode 100644
index 0000000..6773c07
--- /dev/null
+++ b/app/src/main/java/com/sameerasw/airsync/presentation/ui/activities/AutoApproveMirrorActivity.kt
@@ -0,0 +1,89 @@
+package com.sameerasw.airsync.presentation.ui.activities
+
+import android.app.Activity
+import android.content.Context
+import android.content.Intent
+import android.media.projection.MediaProjectionManager
+import android.os.Build
+import android.os.Bundle
+import android.util.Log
+import androidx.activity.ComponentActivity
+import androidx.activity.result.contract.ActivityResultContracts
+import com.sameerasw.airsync.domain.model.MirroringOptions
+import com.sameerasw.airsync.service.ScreenCaptureService
+import com.sameerasw.airsync.utils.MirrorRequestHelper
+import com.sameerasw.airsync.data.local.DataStoreManager
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.launch
+
+/**
+ * Transparent activity that handles auto-approve mirror requests.
+ * Shows the system MediaProjection permission dialog but auto-dismisses
+ * once permission is granted.
+ */
+class AutoApproveMirrorActivity : ComponentActivity() {
+
+ companion object {
+ private const val TAG = "AutoApproveMirror"
+ }
+
+ private val screenCaptureLauncher = registerForActivityResult(
+ ActivityResultContracts.StartActivityForResult()
+ ) { result ->
+ if (result.resultCode == Activity.RESULT_OK) {
+ val data = result.data
+ if (data != null) {
+ val mirroringOptions = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
+ intent.getParcelableExtra(ScreenCaptureService.EXTRA_MIRRORING_OPTIONS, MirroringOptions::class.java)
+ } else {
+ @Suppress("DEPRECATION")
+ intent.getParcelableExtra(ScreenCaptureService.EXTRA_MIRRORING_OPTIONS)
+ }
+
+ mirroringOptions?.let {
+ Log.d(TAG, "Permission granted, starting ScreenCaptureService")
+
+ // Store that we have permission for future auto-approve
+ CoroutineScope(Dispatchers.IO).launch {
+ val dataStore = DataStoreManager(this@AutoApproveMirrorActivity)
+ dataStore.setMirrorPermission(true)
+ }
+
+ val serviceIntent = Intent(this, ScreenCaptureService::class.java).apply {
+ action = ScreenCaptureService.ACTION_START
+ putExtra(ScreenCaptureService.EXTRA_RESULT_CODE, result.resultCode)
+ putExtra(ScreenCaptureService.EXTRA_DATA, data)
+ putExtra(ScreenCaptureService.EXTRA_MIRRORING_OPTIONS, it)
+ }
+ startForegroundService(serviceIntent)
+ }
+ }
+ } else {
+ Log.d(TAG, "Permission denied by user")
+ }
+
+ // Reset pending flag and finish
+ MirrorRequestHelper.resetPendingFlag()
+ finish()
+ }
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+
+ // Make activity transparent
+ window.setBackgroundDrawableResource(android.R.color.transparent)
+
+ Log.d(TAG, "Starting auto-approve mirror flow")
+
+ val mediaProjectionManager = getSystemService(Context.MEDIA_PROJECTION_SERVICE) as MediaProjectionManager
+ val screenCaptureIntent = mediaProjectionManager.createScreenCaptureIntent()
+ screenCaptureLauncher.launch(screenCaptureIntent)
+ }
+
+ override fun onDestroy() {
+ super.onDestroy()
+ // Ensure pending flag is reset if activity is destroyed
+ MirrorRequestHelper.resetPendingFlag()
+ }
+}
diff --git a/app/src/main/java/com/sameerasw/airsync/presentation/ui/activities/PermissionsActivity.kt b/app/src/main/java/com/sameerasw/airsync/presentation/ui/activities/PermissionsActivity.kt
index f6ced9a..5f239ca 100644
--- a/app/src/main/java/com/sameerasw/airsync/presentation/ui/activities/PermissionsActivity.kt
+++ b/app/src/main/java/com/sameerasw/airsync/presentation/ui/activities/PermissionsActivity.kt
@@ -101,6 +101,9 @@ class PermissionsActivity : ComponentActivity() {
onRequestPhonePermission = {
requestPhonePermission()
},
+ onRequestAnswerCallPermission = {
+ requestAnswerCallPermission()
+ },
refreshTrigger = refreshCounter
)
}
@@ -138,6 +141,18 @@ class PermissionsActivity : ComponentActivity() {
}
}
+ private val answerCallPermissionLauncher = registerForActivityResult(
+ ActivityResultContracts.RequestPermission()
+ ) { refreshUI() }
+
+ private fun requestAnswerCallPermission() {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ if (!PermissionUtil.isAnswerPhoneCallsPermissionGranted(this)) {
+ answerCallPermissionLauncher.launch(Manifest.permission.ANSWER_PHONE_CALLS)
+ }
+ }
+ }
+
override fun onResume() {
super.onResume()
// Refresh permissions display when returning to this activity
diff --git a/app/src/main/java/com/sameerasw/airsync/presentation/ui/activities/QRScannerActivity.kt b/app/src/main/java/com/sameerasw/airsync/presentation/ui/activities/QRScannerActivity.kt
index c51f722..3a33b1b 100644
--- a/app/src/main/java/com/sameerasw/airsync/presentation/ui/activities/QRScannerActivity.kt
+++ b/app/src/main/java/com/sameerasw/airsync/presentation/ui/activities/QRScannerActivity.kt
@@ -15,6 +15,7 @@ import android.os.Build
import androidx.camera.core.CameraSelector
import androidx.camera.core.ImageAnalysis
import androidx.camera.core.ImageProxy
+import androidx.camera.core.Preview
import androidx.camera.lifecycle.ProcessCameraProvider
import androidx.camera.view.PreviewView
import androidx.compose.foundation.background
@@ -33,6 +34,8 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.core.content.ContextCompat
import androidx.core.view.WindowCompat
+import androidx.lifecycle.LifecycleOwner
+import com.google.common.util.concurrent.ListenableFuture
import com.google.mlkit.vision.barcode.BarcodeScannerOptions
import com.google.mlkit.vision.barcode.BarcodeScanning
import com.google.mlkit.vision.common.InputImage
@@ -103,7 +106,6 @@ class QRScannerActivity : ComponentActivity() {
}
}
-@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@SuppressLint("UnsafeOptInUsageError")
@Composable
private fun QRScannerScreen(
@@ -164,7 +166,7 @@ private fun QRScannerScreen(
.align(Alignment.Center),
contentAlignment = Alignment.Center
) {
- LoadingIndicator(modifier = Modifier.scale(2f))
+ CircularProgressIndicator(modifier = Modifier.scale(2f))
}
// Back button overlay on top-left
@@ -217,7 +219,7 @@ fun QrCodeScannerView(
modifier = modifier,
factory = { ctx ->
val previewView = PreviewView(ctx)
- val cameraProviderFuture = ProcessCameraProvider.getInstance(ctx)
+ val cameraProviderFuture: ListenableFuture = ProcessCameraProvider.getInstance(ctx)
cameraProviderFuture.addListener({
try {
@@ -225,7 +227,7 @@ fun QrCodeScannerView(
Log.d("QrScanner", "Camera provider obtained")
@Suppress("DEPRECATION")
- val preview = androidx.camera.core.Preview.Builder()
+ val preview = Preview.Builder()
.setTargetResolution(android.util.Size(1280, 720))
.build()
preview.setSurfaceProvider(previewView.surfaceProvider)
@@ -265,7 +267,7 @@ fun QrCodeScannerView(
.build()
cameraProvider.bindToLifecycle(
- ctx as androidx.lifecycle.LifecycleOwner,
+ ctx as LifecycleOwner,
cameraSelector,
preview,
analysis
diff --git a/app/src/main/java/com/sameerasw/airsync/presentation/ui/activities/ScreenShareActivity.kt b/app/src/main/java/com/sameerasw/airsync/presentation/ui/activities/ScreenShareActivity.kt
new file mode 100644
index 0000000..d91641a
--- /dev/null
+++ b/app/src/main/java/com/sameerasw/airsync/presentation/ui/activities/ScreenShareActivity.kt
@@ -0,0 +1,79 @@
+package com.sameerasw.airsync.presentation.ui.activities
+
+import android.app.Activity
+import android.content.Context
+import android.content.Intent
+import android.media.projection.MediaProjectionManager
+import android.os.Build
+import android.os.Bundle
+import android.util.Log
+import androidx.activity.ComponentActivity
+import androidx.activity.result.contract.ActivityResultContracts
+import com.sameerasw.airsync.domain.model.MirroringOptions
+import com.sameerasw.airsync.service.ScreenCaptureService
+import com.sameerasw.airsync.utils.MirrorRequestHelper
+import com.sameerasw.airsync.data.local.DataStoreManager
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.launch
+
+class ScreenShareActivity : ComponentActivity() {
+
+ companion object {
+ private const val TAG = "ScreenShareActivity"
+ }
+
+ private val screenCaptureLauncher = registerForActivityResult(
+ ActivityResultContracts.StartActivityForResult()
+ ) { result ->
+ if (result.resultCode == Activity.RESULT_OK) {
+ val data = result.data
+ if (data != null) {
+ val mirroringOptions = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
+ intent.getParcelableExtra(ScreenCaptureService.EXTRA_MIRRORING_OPTIONS, MirroringOptions::class.java)
+ } else {
+ @Suppress("DEPRECATION")
+ intent.getParcelableExtra(ScreenCaptureService.EXTRA_MIRRORING_OPTIONS)
+ }
+
+ mirroringOptions?.let {
+ Log.d(TAG, "Permission granted, starting ScreenCaptureService")
+
+ // Store that we have permission for future auto-approve
+ CoroutineScope(Dispatchers.IO).launch {
+ val dataStore = DataStoreManager(this@ScreenShareActivity)
+ dataStore.setMirrorPermission(true)
+ }
+
+ val serviceIntent = Intent(this, ScreenCaptureService::class.java).apply {
+ action = ScreenCaptureService.ACTION_START
+ putExtra(ScreenCaptureService.EXTRA_RESULT_CODE, result.resultCode)
+ putExtra(ScreenCaptureService.EXTRA_DATA, data)
+ putExtra(ScreenCaptureService.EXTRA_MIRRORING_OPTIONS, it)
+ }
+ startForegroundService(serviceIntent)
+ }
+ }
+ } else {
+ Log.d(TAG, "Permission denied by user")
+ }
+
+ // Reset pending flag and finish
+ MirrorRequestHelper.resetPendingFlag()
+ finish() // Finish the activity regardless of the result
+ }
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+
+ val mediaProjectionManager = getSystemService(Context.MEDIA_PROJECTION_SERVICE) as MediaProjectionManager
+ val screenCaptureIntent = mediaProjectionManager.createScreenCaptureIntent()
+ screenCaptureLauncher.launch(screenCaptureIntent)
+ }
+
+ override fun onDestroy() {
+ super.onDestroy()
+ // Ensure pending flag is reset if activity is destroyed
+ MirrorRequestHelper.resetPendingFlag()
+ }
+}
diff --git a/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/PermissionCard.kt b/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/PermissionCard.kt
new file mode 100644
index 0000000..fd6df82
--- /dev/null
+++ b/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/PermissionCard.kt
@@ -0,0 +1,307 @@
+package com.sameerasw.airsync.presentation.ui.components
+
+import android.content.Intent
+import androidx.activity.compose.rememberLauncherForActivityResult
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.compose.foundation.layout.*
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.*
+import androidx.compose.material3.*
+import androidx.compose.runtime.*
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.dp
+import com.sameerasw.airsync.models.PermissionCategory
+import com.sameerasw.airsync.models.PermissionGroup
+import com.sameerasw.airsync.models.PermissionInfo
+import com.sameerasw.airsync.utils.PermissionUtil
+
+@Composable
+fun PermissionsOverviewCard(
+ permissionGroups: List,
+ onRefresh: () -> Unit
+) {
+ val context = LocalContext.current
+ val missingCount = permissionGroups.flatMap { it.permissions }.count { !it.isGranted }
+ val requiredMissingCount = permissionGroups.flatMap { it.permissions }.count { !it.isGranted && it.isRequired }
+
+ // Permission launcher for runtime permissions
+ val permissionLauncher = rememberLauncherForActivityResult(
+ contract = ActivityResultContracts.RequestMultiplePermissions()
+ ) { permissions ->
+ onRefresh()
+ }
+
+ Card(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(16.dp),
+ colors = CardDefaults.cardColors(
+ containerColor = if (requiredMissingCount > 0) {
+ MaterialTheme.colorScheme.errorContainer
+ } else if (missingCount > 0) {
+ MaterialTheme.colorScheme.secondaryContainer
+ } else {
+ MaterialTheme.colorScheme.primaryContainer
+ }
+ )
+ ) {
+ Column(
+ modifier = Modifier.padding(16.dp)
+ ) {
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ modifier = Modifier.fillMaxWidth()
+ ) {
+ Icon(
+ imageVector = if (missingCount == 0) Icons.Default.CheckCircle else Icons.Default.Warning,
+ contentDescription = null,
+ modifier = Modifier.size(32.dp),
+ tint = if (requiredMissingCount > 0) {
+ MaterialTheme.colorScheme.error
+ } else if (missingCount > 0) {
+ MaterialTheme.colorScheme.secondary
+ } else {
+ MaterialTheme.colorScheme.primary
+ }
+ )
+
+ Spacer(modifier = Modifier.width(12.dp))
+
+ Column(modifier = Modifier.weight(1f)) {
+ Text(
+ text = if (missingCount == 0) "All Permissions Granted" else "Permissions Needed",
+ style = MaterialTheme.typography.titleMedium,
+ fontWeight = FontWeight.Bold
+ )
+
+ if (missingCount > 0) {
+ Text(
+ text = if (requiredMissingCount > 0) {
+ "$requiredMissingCount required, ${missingCount - requiredMissingCount} optional"
+ } else {
+ "$missingCount optional permissions"
+ },
+ style = MaterialTheme.typography.bodySmall
+ )
+ }
+ }
+
+ if (missingCount > 0) {
+ TextButton(
+ onClick = {
+ // Request all runtime permissions
+ val runtimePermissions = PermissionUtil.getRuntimePermissionsToRequest(context)
+ if (runtimePermissions.isNotEmpty()) {
+ permissionLauncher.launch(runtimePermissions.toTypedArray())
+ }
+ }
+ ) {
+ Text("Grant All")
+ }
+ }
+ }
+
+ if (missingCount > 0) {
+ Spacer(modifier = Modifier.height(8.dp))
+ Text(
+ text = "Some features require additional permissions to work properly.",
+ style = MaterialTheme.typography.bodySmall
+ )
+ }
+ }
+ }
+}
+
+@Composable
+fun PermissionGroupsList(
+ permissionGroups: List,
+ onRefresh: () -> Unit
+) {
+ LazyColumn(
+ modifier = Modifier.fillMaxSize(),
+ contentPadding = PaddingValues(16.dp),
+ verticalArrangement = Arrangement.spacedBy(12.dp)
+ ) {
+ items(permissionGroups.filter { it.permissions.isNotEmpty() }) { group ->
+ PermissionGroupCard(
+ group = group,
+ onRefresh = onRefresh
+ )
+ }
+ }
+}
+
+@Composable
+fun PermissionGroupCard(
+ group: PermissionGroup,
+ onRefresh: () -> Unit
+) {
+ val context = LocalContext.current
+ val missingCount = group.permissions.count { !it.isGranted }
+
+ // Permission launcher for runtime permissions
+ val permissionLauncher = rememberLauncherForActivityResult(
+ contract = ActivityResultContracts.RequestMultiplePermissions()
+ ) { permissions ->
+ onRefresh()
+ }
+
+ Card(
+ modifier = Modifier.fillMaxWidth()
+ ) {
+ Column(
+ modifier = Modifier.padding(16.dp)
+ ) {
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ modifier = Modifier.fillMaxWidth()
+ ) {
+ Icon(
+ imageVector = getCategoryIcon(group.category),
+ contentDescription = null,
+ modifier = Modifier.size(24.dp),
+ tint = MaterialTheme.colorScheme.primary
+ )
+
+ Spacer(modifier = Modifier.width(12.dp))
+
+ Column(modifier = Modifier.weight(1f)) {
+ Text(
+ text = group.title,
+ style = MaterialTheme.typography.titleMedium,
+ fontWeight = FontWeight.Bold
+ )
+ Text(
+ text = group.description,
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
+
+ if (missingCount > 0) {
+ Badge {
+ Text(missingCount.toString())
+ }
+ }
+ }
+
+ Spacer(modifier = Modifier.height(12.dp))
+
+ group.permissions.forEach { permission ->
+ PermissionItem(
+ permission = permission,
+ onRequestPermission = {
+ if (permission.requiresSpecialHandling) {
+ handleSpecialPermission(context, permission.permission)
+ } else {
+ permissionLauncher.launch(arrayOf(permission.permission))
+ }
+ }
+ )
+ Spacer(modifier = Modifier.height(8.dp))
+ }
+ }
+ }
+}
+
+@Composable
+fun PermissionItem(
+ permission: PermissionInfo,
+ onRequestPermission: () -> Unit
+) {
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ modifier = Modifier.fillMaxWidth()
+ ) {
+ Icon(
+ imageVector = if (permission.isGranted) Icons.Default.CheckCircle else Icons.Default.Info,
+ contentDescription = null,
+ modifier = Modifier.size(20.dp),
+ tint = if (permission.isGranted) {
+ MaterialTheme.colorScheme.primary
+ } else if (permission.isRequired) {
+ MaterialTheme.colorScheme.error
+ } else {
+ MaterialTheme.colorScheme.onSurfaceVariant
+ }
+ )
+
+ Spacer(modifier = Modifier.width(12.dp))
+
+ Column(modifier = Modifier.weight(1f)) {
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ Text(
+ text = permission.displayName,
+ style = MaterialTheme.typography.bodyMedium,
+ fontWeight = if (permission.isRequired) FontWeight.Bold else FontWeight.Normal
+ )
+ if (permission.isRequired) {
+ Spacer(modifier = Modifier.width(4.dp))
+ Text(
+ text = "Required",
+ style = MaterialTheme.typography.labelSmall,
+ color = MaterialTheme.colorScheme.error
+ )
+ }
+ }
+ Text(
+ text = permission.description,
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
+
+ if (!permission.isGranted) {
+ TextButton(onClick = onRequestPermission) {
+ Text("Grant")
+ }
+ } else {
+ Icon(
+ imageVector = Icons.Default.Check,
+ contentDescription = "Granted",
+ tint = MaterialTheme.colorScheme.primary
+ )
+ }
+ }
+}
+
+private fun getCategoryIcon(category: PermissionCategory) = when (category) {
+ PermissionCategory.CORE -> Icons.Default.Settings
+ PermissionCategory.MESSAGING -> Icons.Default.Message
+ PermissionCategory.CALLS -> Icons.Default.Phone
+ PermissionCategory.HEALTH -> Icons.Default.FavoriteBorder
+ PermissionCategory.LOCATION -> Icons.Default.LocationOn
+ PermissionCategory.STORAGE -> Icons.Default.Folder
+ PermissionCategory.SPECIAL -> Icons.Default.Security
+}
+
+private fun handleSpecialPermission(context: android.content.Context, permission: String) {
+ when (permission) {
+ PermissionUtil.NOTIFICATION_ACCESS -> {
+ PermissionUtil.openNotificationListenerSettings(context)
+ }
+ PermissionUtil.ACCESSIBILITY_SERVICE -> {
+ PermissionUtil.openAccessibilitySettings(context)
+ }
+ PermissionUtil.BACKGROUND_APP_USAGE -> {
+ PermissionUtil.openBatteryOptimizationSettings(context)
+ }
+ PermissionUtil.HEALTH_CONNECT -> {
+ PermissionUtil.openHealthConnectPermissions(context)
+ }
+ "MANAGE_EXTERNAL_STORAGE" -> {
+ PermissionUtil.openManageExternalStorageSettings(context)
+ }
+ "SYSTEM_ALERT_WINDOW" -> {
+ PermissionUtil.openOverlaySettings(context)
+ }
+ else -> {
+ PermissionUtil.openAppSettings(context)
+ }
+ }
+}
diff --git a/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/SettingsView.kt b/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/SettingsView.kt
index 0ae7e9e..6120fe1 100644
--- a/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/SettingsView.kt
+++ b/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/SettingsView.kt
@@ -38,6 +38,7 @@ import com.sameerasw.airsync.presentation.ui.components.cards.QuickSettingsTipCa
import com.sameerasw.airsync.presentation.ui.components.cards.ClipboardFeaturesCard
import com.sameerasw.airsync.presentation.ui.components.cards.SendNowPlayingCard
import com.sameerasw.airsync.presentation.ui.components.cards.SmartspacerCard
+import com.sameerasw.airsync.presentation.ui.components.cards.BluetoothCard
import com.sameerasw.airsync.utils.HapticUtil
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
@@ -77,7 +78,9 @@ fun SettingsView(
scope: CoroutineScope = androidx.compose.runtime.rememberCoroutineScope(),
onSendMessage: (String) -> Unit = {},
onExport: (String) -> Unit = {},
- onImport: () -> Unit = {}
+ onImport: () -> Unit = {},
+ onNavigateToHealth: () -> Unit = {},
+ onNavigateToFileTransfer: () -> Unit = {}
) {
val haptics = LocalHapticFeedback.current
@@ -154,6 +157,44 @@ fun SettingsView(
ExpandNetworkingCard(context)
}
+ // Features Section - Health & File Transfer
+ RoundedCardContainer {
+ // Health & Fitness Button
+ Button(
+ onClick = {
+ HapticUtil.performClick(haptics)
+ onNavigateToHealth()
+ },
+ modifier = Modifier.fillMaxWidth(),
+ shape = MaterialTheme.shapes.extraSmall
+ ) {
+ Text("Health & Fitness")
+ }
+
+ // File Transfer Button (only when connected)
+ AnimatedVisibility(
+ visible = uiState.isConnected,
+ enter = expandVertically() + fadeIn(),
+ exit = shrinkVertically() + fadeOut()
+ ) {
+ Button(
+ onClick = {
+ HapticUtil.performClick(haptics)
+ onNavigateToFileTransfer()
+ },
+ modifier = Modifier.fillMaxWidth(),
+ shape = MaterialTheme.shapes.extraSmall
+ ) {
+ Text("Send Files to Mac")
+ }
+ }
+ }
+
+ // Bluetooth Connection Section
+ RoundedCardContainer {
+ BluetoothCard()
+ }
+
// Device Info Section
RoundedCardContainer {
DeviceInfoCard(
diff --git a/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/cards/BluetoothCard.kt b/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/cards/BluetoothCard.kt
new file mode 100644
index 0000000..688a488
--- /dev/null
+++ b/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/cards/BluetoothCard.kt
@@ -0,0 +1,847 @@
+package com.sameerasw.airsync.presentation.ui.components.cards
+
+import android.Manifest
+import android.bluetooth.BluetoothDevice
+import android.content.Intent
+import android.content.pm.PackageManager
+import android.net.Uri
+import android.os.Build
+import android.provider.Settings
+import androidx.activity.compose.rememberLauncherForActivityResult
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.compose.animation.AnimatedVisibility
+import androidx.compose.animation.animateContentSize
+import androidx.compose.animation.expandVertically
+import androidx.compose.animation.shrinkVertically
+import androidx.compose.foundation.BorderStroke
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.foundation.text.KeyboardOptions
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Bluetooth
+import androidx.compose.material.icons.filled.BluetoothConnected
+import androidx.compose.material.icons.filled.BluetoothSearching
+import androidx.compose.material.icons.filled.Computer
+import androidx.compose.material.icons.filled.Delete
+import androidx.compose.material.icons.filled.ExpandLess
+import androidx.compose.material.icons.filled.ExpandMore
+import androidx.compose.material.icons.filled.Link
+import androidx.compose.material.icons.filled.Refresh
+import androidx.compose.material.icons.filled.Settings
+import androidx.compose.material.icons.filled.SignalCellular4Bar
+import androidx.compose.material3.AlertDialog
+import androidx.compose.material3.Button
+import androidx.compose.material3.ButtonDefaults
+import androidx.compose.material3.Card
+import androidx.compose.material3.CardDefaults
+import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.OutlinedButton
+import androidx.compose.material3.OutlinedCard
+import androidx.compose.material3.OutlinedTextField
+import androidx.compose.material3.Switch
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.DisposableEffect
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.platform.LocalHapticFeedback
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.input.KeyboardType
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.unit.dp
+import androidx.core.content.ContextCompat
+import com.sameerasw.airsync.utils.BluetoothHelper
+import com.sameerasw.airsync.utils.BluetoothSyncManager
+import com.sameerasw.airsync.utils.HapticUtil
+
+@Composable
+fun BluetoothCard(
+ modifier: Modifier = Modifier,
+ onDeviceConnected: ((BluetoothDevice) -> Unit)? = null
+) {
+ val context = LocalContext.current
+ val haptics = LocalHapticFeedback.current
+
+ var isExpanded by remember { mutableStateOf(false) }
+ var isScanning by remember { mutableStateOf(false) }
+ var hasPermissions by remember { mutableStateOf(false) }
+ var permissionDeniedPermanently by remember { mutableStateOf(false) }
+ var autoConnectEnabled by remember { mutableStateOf(true) }
+
+ // BluetoothSyncManager states
+ val connectionState by BluetoothSyncManager.connectionState.collectAsState()
+ val pairingState by BluetoothSyncManager.pairingState.collectAsState()
+ val pairedDevice by BluetoothSyncManager.pairedDevice.collectAsState()
+ val connectedDeviceName by BluetoothSyncManager.connectedDeviceName.collectAsState()
+ val isAdvertisingState by BluetoothSyncManager.isAdvertising.collectAsState()
+
+ val discoveredDevices by BluetoothSyncManager.getHelper()?.discoveredDevices?.collectAsState()
+ ?: remember { mutableStateOf(emptyList()) }
+
+
+ // Update isScanning based on connection state
+ LaunchedEffect(connectionState) {
+ isScanning = connectionState is BluetoothSyncManager.ConnectionState.Scanning
+ }
+
+ val requiredPermissions = remember {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
+ arrayOf(
+ Manifest.permission.BLUETOOTH_ADVERTISE,
+ Manifest.permission.BLUETOOTH_CONNECT,
+ Manifest.permission.BLUETOOTH_SCAN
+ )
+ } else {
+ arrayOf(
+ Manifest.permission.BLUETOOTH,
+ Manifest.permission.BLUETOOTH_ADMIN,
+ Manifest.permission.ACCESS_FINE_LOCATION
+ )
+ }
+ }
+
+ fun checkPermissions(): Boolean {
+ return requiredPermissions.all {
+ ContextCompat.checkSelfPermission(context, it) == PackageManager.PERMISSION_GRANTED
+ }
+ }
+
+ fun initBluetooth() {
+ BluetoothSyncManager.initialize(context)
+ BluetoothSyncManager.startAdvertising()
+ BluetoothSyncManager.refreshConnectionState() // Ensure connection state is accurate
+ autoConnectEnabled = BluetoothSyncManager.isAutoConnectEnabled()
+ }
+
+ val permissionLauncher = rememberLauncherForActivityResult(
+ contract = ActivityResultContracts.RequestMultiplePermissions()
+ ) { permissions ->
+ val allGranted = permissions.values.all { it }
+ hasPermissions = allGranted
+ if (allGranted) {
+ initBluetooth()
+ permissionDeniedPermanently = false
+ } else {
+ val anyPermanentlyDenied = permissions.keys.any { permission ->
+ !permissions[permission]!! &&
+ (context as? android.app.Activity)?.let { activity ->
+ !androidx.core.app.ActivityCompat.shouldShowRequestPermissionRationale(activity, permission)
+ } ?: false
+ }
+ permissionDeniedPermanently = anyPermanentlyDenied
+ }
+ }
+
+ LaunchedEffect(Unit) {
+ hasPermissions = checkPermissions()
+ if (hasPermissions) {
+ initBluetooth()
+ }
+ }
+
+ DisposableEffect(Unit) {
+ onDispose {
+ BluetoothSyncManager.stopScanning()
+ BluetoothSyncManager.stopAdvertising()
+ }
+ }
+
+ // Pairing PIN Dialog
+ if (pairingState is BluetoothSyncManager.PairingState.ConfirmationRequired) {
+ val code = (pairingState as BluetoothSyncManager.PairingState.ConfirmationRequired).code
+ PairingPinDialog(
+ expectedCode = code,
+ onConfirm = {
+ HapticUtil.performClick(haptics)
+ BluetoothSyncManager.acceptPairing()
+ },
+ onDismiss = {
+ BluetoothSyncManager.rejectPairing()
+ }
+ )
+ }
+
+ Card(
+ modifier = modifier
+ .fillMaxWidth()
+ .animateContentSize(),
+ colors = CardDefaults.cardColors(
+ containerColor = MaterialTheme.colorScheme.surfaceVariant
+ )
+ ) {
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(16.dp),
+ verticalArrangement = Arrangement.spacedBy(12.dp)
+ ) {
+ // Header
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .clickable {
+ HapticUtil.performClick(haptics)
+ isExpanded = !isExpanded
+ },
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(8.dp)
+ ) {
+ Icon(
+ imageVector = when (connectionState) {
+ is BluetoothSyncManager.ConnectionState.Connected -> Icons.Default.BluetoothConnected
+ is BluetoothSyncManager.ConnectionState.Scanning -> Icons.Default.BluetoothSearching
+ else -> Icons.Default.Bluetooth
+ },
+ contentDescription = null,
+ tint = if (connectionState is BluetoothSyncManager.ConnectionState.Connected)
+ MaterialTheme.colorScheme.primary
+ else
+ MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ Column {
+ Text(
+ text = "Bluetooth Connection",
+ style = MaterialTheme.typography.titleMedium,
+ fontWeight = FontWeight.Bold
+ )
+ Text(
+ text = when (connectionState) {
+ is BluetoothSyncManager.ConnectionState.Connected ->
+ "Connected to ${connectedDeviceName ?: "device"}"
+ is BluetoothSyncManager.ConnectionState.Connecting -> "Connecting..."
+ is BluetoothSyncManager.ConnectionState.Scanning -> "Scanning..."
+ is BluetoothSyncManager.ConnectionState.Error -> "Error"
+ else -> pairedDevice?.let { "Paired: ${it.name}" } ?: "Tap to expand"
+ },
+ style = MaterialTheme.typography.bodySmall,
+ color = if (connectionState is BluetoothSyncManager.ConnectionState.Connected)
+ MaterialTheme.colorScheme.primary
+ else
+ MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
+ }
+
+ Icon(
+ imageVector = if (isExpanded) Icons.Default.ExpandLess else Icons.Default.ExpandMore,
+ contentDescription = if (isExpanded) "Collapse" else "Expand"
+ )
+ }
+
+ AnimatedVisibility(
+ visible = isExpanded,
+ enter = expandVertically(),
+ exit = shrinkVertically()
+ ) {
+ Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
+ if (!hasPermissions) {
+ PermissionCard(
+ permissionDeniedPermanently = permissionDeniedPermanently,
+ onGrantPermissions = {
+ HapticUtil.performClick(haptics)
+ permissionLauncher.launch(requiredPermissions)
+ },
+ onOpenSettings = {
+ HapticUtil.performClick(haptics)
+ val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply {
+ data = Uri.fromParts("package", context.packageName, null)
+ flags = Intent.FLAG_ACTIVITY_NEW_TASK
+ }
+ context.startActivity(intent)
+ },
+ onCheckAgain = {
+ HapticUtil.performClick(haptics)
+ hasPermissions = checkPermissions()
+ if (hasPermissions) {
+ permissionDeniedPermanently = false
+ initBluetooth()
+ }
+ }
+ )
+ } else {
+ // Paired device section
+ pairedDevice?.let { paired ->
+ PairedDeviceCard(
+ deviceName = paired.name,
+ isConnected = connectionState is BluetoothSyncManager.ConnectionState.Connected,
+ isConnecting = connectionState is BluetoothSyncManager.ConnectionState.Connecting,
+ autoConnectEnabled = autoConnectEnabled,
+ onConnect = {
+ HapticUtil.performClick(haptics)
+ BluetoothSyncManager.tryAutoConnect()
+ },
+ onDisconnect = {
+ HapticUtil.performClick(haptics)
+ BluetoothSyncManager.disconnect()
+ },
+ onForget = {
+ HapticUtil.performClick(haptics)
+ BluetoothSyncManager.forgetPairedDevice()
+ },
+ onAutoConnectChanged = { enabled ->
+ HapticUtil.performClick(haptics)
+ autoConnectEnabled = enabled
+ BluetoothSyncManager.setAutoConnectEnabled(enabled)
+ }
+ )
+ }
+
+ // Scan controls
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.spacedBy(8.dp)
+ ) {
+ Button(
+ onClick = {
+ HapticUtil.performClick(haptics)
+ if (isScanning) {
+ BluetoothSyncManager.stopScanning()
+ } else {
+ BluetoothSyncManager.startScanning()
+ }
+ },
+ modifier = Modifier.weight(1f)
+ ) {
+ if (isScanning) {
+ CircularProgressIndicator(
+ modifier = Modifier.size(16.dp),
+ strokeWidth = 2.dp,
+ color = MaterialTheme.colorScheme.onPrimary
+ )
+ Spacer(modifier = Modifier.width(8.dp))
+ Text("Stop Scan")
+ } else {
+ Icon(
+ imageVector = Icons.Default.BluetoothSearching,
+ contentDescription = null,
+ modifier = Modifier.size(16.dp)
+ )
+ Spacer(modifier = Modifier.width(8.dp))
+ Text("Scan Devices")
+ }
+ }
+
+ OutlinedButton(
+ onClick = {
+ HapticUtil.performClick(haptics)
+ if (isAdvertisingState) {
+ BluetoothSyncManager.stopAdvertising()
+ } else {
+ BluetoothSyncManager.startAdvertising()
+ }
+ },
+ colors = if (isAdvertisingState) {
+ ButtonDefaults.outlinedButtonColors(
+ containerColor = MaterialTheme.colorScheme.primaryContainer,
+ contentColor = MaterialTheme.colorScheme.primary
+ )
+ } else {
+ ButtonDefaults.outlinedButtonColors()
+ }
+ ) {
+ if (isAdvertisingState) {
+ Icon(
+ imageVector = Icons.Default.BluetoothConnected,
+ contentDescription = null,
+ modifier = Modifier.size(16.dp)
+ )
+ Spacer(modifier = Modifier.width(4.dp))
+ Text("Visible")
+ } else {
+ Text("Make Visible")
+ }
+ }
+ }
+
+ // Pairing state indicator
+ when (pairingState) {
+ is BluetoothSyncManager.PairingState.WaitingForConfirmation -> {
+ val code = (pairingState as BluetoothSyncManager.PairingState.WaitingForConfirmation).code
+ Card(
+ colors = CardDefaults.cardColors(
+ containerColor = MaterialTheme.colorScheme.tertiaryContainer
+ )
+ ) {
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(12.dp),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.spacedBy(8.dp)
+ ) {
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.Center
+ ) {
+ CircularProgressIndicator(
+ modifier = Modifier.size(16.dp),
+ strokeWidth = 2.dp
+ )
+ Spacer(modifier = Modifier.width(8.dp))
+ Text("Waiting for confirmation on other device...")
+ }
+
+ Text(
+ text = code,
+ style = MaterialTheme.typography.headlineMedium,
+ fontWeight = FontWeight.Bold
+ )
+ }
+ }
+ }
+ is BluetoothSyncManager.PairingState.Success -> {
+ Card(
+ colors = CardDefaults.cardColors(
+ containerColor = MaterialTheme.colorScheme.primaryContainer
+ )
+ ) {
+ Text(
+ text = "✓ Pairing successful!",
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(12.dp),
+ textAlign = TextAlign.Center,
+ color = MaterialTheme.colorScheme.onPrimaryContainer
+ )
+ }
+ }
+ is BluetoothSyncManager.PairingState.Failed -> {
+ Card(
+ colors = CardDefaults.cardColors(
+ containerColor = MaterialTheme.colorScheme.errorContainer
+ )
+ ) {
+ Text(
+ text = "✗ Pairing failed: ${(pairingState as BluetoothSyncManager.PairingState.Failed).reason}",
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(12.dp),
+ textAlign = TextAlign.Center,
+ color = MaterialTheme.colorScheme.onErrorContainer
+ )
+ }
+ }
+ else -> {}
+ }
+
+ // Discovered devices
+ if (discoveredDevices.isNotEmpty()) {
+ Text(
+ text = "Discovered Devices (${discoveredDevices.size})",
+ style = MaterialTheme.typography.labelMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+
+ LazyColumn(
+ modifier = Modifier.height(200.dp),
+ verticalArrangement = Arrangement.spacedBy(8.dp)
+ ) {
+ items(discoveredDevices) { device ->
+ DiscoveredDeviceItem(
+ device = device,
+ onPair = {
+ HapticUtil.performClick(haptics)
+ BluetoothSyncManager.initiatePairing(device.device)
+ }
+ )
+ }
+ }
+ } else if (isScanning) {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(vertical = 16.dp),
+ horizontalArrangement = Arrangement.Center,
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ CircularProgressIndicator(
+ modifier = Modifier.size(24.dp),
+ strokeWidth = 2.dp
+ )
+ Spacer(modifier = Modifier.width(12.dp))
+ Text(
+ text = "Searching for AirSync devices...",
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
+ } else {
+ Text(
+ text = "Tap 'Scan Devices' to find nearby AirSync-enabled Macs",
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
+ }
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun PairingPinDialog(
+ expectedCode: String,
+ onConfirm: () -> Unit,
+ onDismiss: () -> Unit
+) {
+ var pin by remember { mutableStateOf("") }
+ var error by remember { mutableStateOf(null) }
+
+ AlertDialog(
+ onDismissRequest = onDismiss,
+ title = {
+ Text(
+ text = "Enter Pairing PIN",
+ style = MaterialTheme.typography.headlineSmall
+ )
+ },
+ text = {
+ Column(
+ verticalArrangement = Arrangement.spacedBy(16.dp),
+ horizontalAlignment = Alignment.CenterHorizontally
+ ) {
+ Text(
+ text = "Enter the 6-digit code shown on your Mac",
+ style = MaterialTheme.typography.bodyMedium,
+ textAlign = TextAlign.Center
+ )
+
+ OutlinedTextField(
+ value = pin,
+ onValueChange = {
+ if (it.length <= 6 && it.all { char -> char.isDigit() }) {
+ pin = it
+ error = null
+ }
+ },
+ label = { Text("PIN") },
+ singleLine = true,
+ keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
+ modifier = Modifier.fillMaxWidth(),
+ isError = error != null
+ )
+
+ if (error != null) {
+ Text(
+ text = error!!,
+ color = MaterialTheme.colorScheme.error,
+ style = MaterialTheme.typography.bodySmall
+ )
+ }
+ }
+ },
+ confirmButton = {
+ Button(
+ onClick = {
+ if (pin == expectedCode) {
+ onConfirm()
+ } else {
+ error = "Incorrect PIN"
+ }
+ },
+ enabled = pin.length == 6
+ ) {
+ Text("Pair")
+ }
+ },
+ dismissButton = {
+ TextButton(onClick = onDismiss) {
+ Text("Cancel")
+ }
+ }
+ )
+}
+
+@Composable
+private fun PermissionCard(
+ permissionDeniedPermanently: Boolean,
+ onGrantPermissions: () -> Unit,
+ onOpenSettings: () -> Unit,
+ onCheckAgain: () -> Unit
+) {
+ Card(
+ colors = CardDefaults.cardColors(
+ containerColor = MaterialTheme.colorScheme.errorContainer
+ )
+ ) {
+ Column(
+ modifier = Modifier.padding(12.dp),
+ verticalArrangement = Arrangement.spacedBy(8.dp)
+ ) {
+ Text(
+ text = "Bluetooth permissions required",
+ style = MaterialTheme.typography.bodyMedium,
+ fontWeight = FontWeight.Medium,
+ color = MaterialTheme.colorScheme.onErrorContainer
+ )
+ Text(
+ text = if (permissionDeniedPermanently)
+ "Permission was denied. Please enable in Settings."
+ else
+ "Grant permissions to scan for nearby devices",
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onErrorContainer.copy(alpha = 0.8f)
+ )
+
+ Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
+ if (permissionDeniedPermanently) {
+ Button(onClick = onOpenSettings) {
+ Icon(Icons.Default.Settings, null, Modifier.size(16.dp))
+ Spacer(Modifier.width(4.dp))
+ Text("App Settings")
+ }
+ } else {
+ Button(onClick = onGrantPermissions) {
+ Text("Grant Permissions")
+ }
+ }
+
+ TextButton(onClick = onCheckAgain) {
+ Icon(Icons.Default.Refresh, null, Modifier.size(16.dp))
+ Spacer(Modifier.width(4.dp))
+ Text("Check Again")
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun PairedDeviceCard(
+ deviceName: String,
+ isConnected: Boolean,
+ isConnecting: Boolean,
+ autoConnectEnabled: Boolean,
+ onConnect: () -> Unit,
+ onDisconnect: () -> Unit,
+ onForget: () -> Unit,
+ onAutoConnectChanged: (Boolean) -> Unit
+) {
+ Card(
+ colors = CardDefaults.cardColors(
+ containerColor = if (isConnected)
+ MaterialTheme.colorScheme.primaryContainer
+ else
+ MaterialTheme.colorScheme.surfaceVariant
+ )
+ ) {
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(12.dp),
+ verticalArrangement = Arrangement.spacedBy(12.dp)
+ ) {
+ // Device info row
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(8.dp)
+ ) {
+ Icon(
+ imageVector = if (isConnected) Icons.Default.BluetoothConnected else Icons.Default.Link,
+ contentDescription = null,
+ tint = if (isConnected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ Column {
+ Text(
+ text = deviceName,
+ style = MaterialTheme.typography.bodyMedium,
+ fontWeight = FontWeight.Medium
+ )
+ Text(
+ text = if (isConnected) "Connected" else if (isConnecting) "Connecting..." else "Paired",
+ style = MaterialTheme.typography.bodySmall,
+ color = if (isConnected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
+ }
+
+ // Action buttons row
+ Column(
+ verticalArrangement = Arrangement.spacedBy(8.dp)
+ ) {
+ // Connect/Disconnect button
+ if (isConnected) {
+ OutlinedButton(
+ onClick = onDisconnect,
+ modifier = Modifier.fillMaxWidth()
+ ) {
+ Text("Disconnect")
+ }
+ } else if (isConnecting) {
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.Center,
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ CircularProgressIndicator(
+ modifier = Modifier.size(24.dp),
+ strokeWidth = 2.dp
+ )
+ Spacer(modifier = Modifier.width(8.dp))
+ Text("Connecting...")
+ }
+ } else {
+ Button(
+ onClick = onConnect,
+ modifier = Modifier.fillMaxWidth()
+ ) {
+ Text("Connect")
+ }
+ }
+
+ // Remove Paired Device button
+ OutlinedButton(
+ onClick = onForget,
+ modifier = Modifier.fillMaxWidth(),
+ colors = ButtonDefaults.outlinedButtonColors(
+ contentColor = MaterialTheme.colorScheme.error
+ ),
+ border = BorderStroke(1.dp, MaterialTheme.colorScheme.error)
+ ) {
+ Icon(
+ imageVector = Icons.Default.Delete,
+ contentDescription = null,
+ modifier = Modifier.size(16.dp)
+ )
+ Spacer(modifier = Modifier.width(8.dp))
+ Text("Remove Paired Device")
+ }
+ }
+
+ // Auto-connect toggle
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Text(
+ text = "Auto-connect on launch",
+ style = MaterialTheme.typography.bodySmall
+ )
+ Switch(
+ checked = autoConnectEnabled,
+ onCheckedChange = onAutoConnectChanged
+ )
+ }
+ }
+ }
+}
+
+@Composable
+private fun DiscoveredDeviceItem(
+ device: BluetoothHelper.DiscoveredDevice,
+ onPair: () -> Unit
+) {
+ Card(
+ modifier = Modifier.fillMaxWidth(),
+ colors = CardDefaults.cardColors(
+ containerColor = MaterialTheme.colorScheme.surface
+ )
+ ) {
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(12.dp),
+ verticalArrangement = Arrangement.spacedBy(8.dp)
+ ) {
+ // Device info row
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(12.dp),
+ modifier = Modifier.weight(1f)
+ ) {
+ Icon(
+ imageVector = Icons.Default.Computer,
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.primary
+ )
+ Column(modifier = Modifier.weight(1f)) {
+ Text(
+ text = device.name.ifEmpty { "AirSync Device" },
+ style = MaterialTheme.typography.bodyMedium,
+ fontWeight = FontWeight.Medium,
+ maxLines = 1,
+ overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis
+ )
+ device.deviceInfo?.let { info ->
+ Text(
+ text = info.deviceModel ?: info.deviceType,
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
+ }
+ }
+
+ // Signal strength
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(4.dp)
+ ) {
+ val signalStrength = when {
+ device.rssi > -50 -> "Strong"
+ device.rssi > -70 -> "Good"
+ else -> "Weak"
+ }
+ Text(
+ text = signalStrength,
+ style = MaterialTheme.typography.labelSmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ Icon(
+ imageVector = Icons.Default.SignalCellular4Bar,
+ contentDescription = null,
+ modifier = Modifier.size(16.dp),
+ tint = when {
+ device.rssi > -50 -> MaterialTheme.colorScheme.primary
+ device.rssi > -70 -> MaterialTheme.colorScheme.tertiary
+ else -> MaterialTheme.colorScheme.error
+ }
+ )
+ }
+ }
+
+ // Pair button on its own row
+ Button(
+ onClick = onPair,
+ modifier = Modifier.fillMaxWidth(),
+ colors = ButtonDefaults.buttonColors(
+ containerColor = MaterialTheme.colorScheme.primary
+ )
+ ) {
+ Text("Pair Device")
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/cards/ConnectionStatusCard.kt b/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/cards/ConnectionStatusCard.kt
index f09f937..aec7a9d 100644
--- a/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/cards/ConnectionStatusCard.kt
+++ b/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/cards/ConnectionStatusCard.kt
@@ -13,9 +13,8 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
-import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
+import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
-import androidx.compose.material3.LoadingIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Text
@@ -33,7 +32,6 @@ import com.sameerasw.airsync.domain.model.UiState
import com.sameerasw.airsync.utils.DevicePreviewResolver
import com.sameerasw.airsync.utils.HapticUtil
-@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun ConnectionStatusCard(
isConnected: Boolean,
@@ -149,11 +147,11 @@ fun ConnectionStatusCard(
}
if (isConnecting) {
- Column(horizontalAlignment = Alignment.CenterHorizontally) { LoadingIndicator() }
+ Column(horizontalAlignment = Alignment.CenterHorizontally) { CircularProgressIndicator(modifier = Modifier.padding(end = 8.dp)) }
}
if (isConnected) {
- Column(horizontalAlignment = Alignment.CenterHorizontally) { LoadingIndicator() }
+ Column(horizontalAlignment = Alignment.CenterHorizontally) { CircularProgressIndicator(modifier = Modifier.padding(end = 8.dp)) }
// Icon(
// painter = painterResource(id = com.sameerasw.airsync.R.drawable.rounded_devices_24),
// contentDescription = "Connected",
diff --git a/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/cards/ExpandNetworkingCard.kt b/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/cards/ExpandNetworkingCard.kt
index 69ea98c..5bfe6e6 100644
--- a/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/cards/ExpandNetworkingCard.kt
+++ b/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/cards/ExpandNetworkingCard.kt
@@ -2,23 +2,24 @@ package com.sameerasw.airsync.presentation.ui.components.cards
import android.content.Context
import androidx.compose.foundation.layout.*
-import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Card
-import androidx.compose.material3.CardDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.runtime.*
+import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.unit.dp
import com.sameerasw.airsync.data.local.DataStoreManager
-import com.sameerasw.airsync.ui.theme.minCornerRadius
+import com.sameerasw.airsync.utils.HapticUtil
import kotlinx.coroutines.launch
@Composable
fun ExpandNetworkingCard(context: Context) {
val ds = remember { DataStoreManager(context) }
val scope = rememberCoroutineScope()
+ val haptics = LocalHapticFeedback.current
val enabledFlow = ds.getExpandNetworkingEnabled()
var enabled by remember { mutableStateOf(false) }
@@ -29,28 +30,28 @@ fun ExpandNetworkingCard(context: Context) {
}
Card(
- modifier = Modifier
- .fillMaxWidth()
- .padding(vertical = 0.dp, horizontal = 0.dp),
+ modifier = Modifier.fillMaxWidth(),
shape = MaterialTheme.shapes.extraSmall,
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
- horizontalArrangement = Arrangement.SpaceBetween
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically
) {
- Column {
+ Column(modifier = Modifier.weight(1f)) {
Text("Expand networking", style = MaterialTheme.typography.titleMedium)
Text(
"Allow connecting to device outside the local network",
- modifier = Modifier.padding(top = 4.dp),
- style = MaterialTheme.typography.bodySmall
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
Switch(
checked = enabled,
onCheckedChange = {
+ if (it) HapticUtil.performToggleOn(haptics) else HapticUtil.performToggleOff(haptics)
enabled = it
scope.launch {
ds.setExpandNetworkingEnabled(it)
diff --git a/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/cards/IncomingFileTransferCard.kt b/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/cards/IncomingFileTransferCard.kt
new file mode 100644
index 0000000..8a5b3c8
--- /dev/null
+++ b/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/cards/IncomingFileTransferCard.kt
@@ -0,0 +1,280 @@
+package com.sameerasw.airsync.presentation.ui.components.cards
+
+import androidx.compose.animation.AnimatedVisibility
+import androidx.compose.animation.animateContentSize
+import androidx.compose.animation.expandVertically
+import androidx.compose.animation.fadeIn
+import androidx.compose.animation.fadeOut
+import androidx.compose.animation.shrinkVertically
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Cancel
+import androidx.compose.material.icons.filled.CheckCircle
+import androidx.compose.material.icons.filled.CloudDownload
+import androidx.compose.material.icons.filled.Error
+import androidx.compose.material3.Card
+import androidx.compose.material3.CardDefaults
+import androidx.compose.material3.Icon
+import androidx.compose.material3.LinearProgressIndicator
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableLongStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.dp
+import com.sameerasw.airsync.utils.FileReceiveManager
+import kotlinx.coroutines.delay
+
+/**
+ * Card showing current incoming file transfer progress
+ * Automatically observes FileReceiveManager's active transfers
+ */
+@Composable
+fun IncomingFileTransferCard(
+ modifier: Modifier = Modifier
+) {
+ val activeTransfers by FileReceiveManager.activeTransfersFlow.collectAsState()
+
+ // Force recomposition every second to update elapsed time
+ var tick by remember { mutableLongStateOf(0L) }
+ LaunchedEffect(activeTransfers.isNotEmpty()) {
+ while (activeTransfers.isNotEmpty()) {
+ delay(1000)
+ tick = System.currentTimeMillis()
+ }
+ }
+
+ // Convert to display format
+ val transfers = activeTransfers.map { (id, state) ->
+ FileTransferInfo(
+ id = id,
+ fileName = state.fileName,
+ totalSize = state.fileSize,
+ receivedSize = state.bytesReceived,
+ progress = if (state.fileSize > 0) state.bytesReceived.toFloat() / state.fileSize.toFloat() else 0f,
+ status = when (state.status) {
+ FileReceiveManager.TransferStatus.PENDING -> TransferStatus.PENDING
+ FileReceiveManager.TransferStatus.TRANSFERRING -> TransferStatus.TRANSFERRING
+ FileReceiveManager.TransferStatus.COMPLETED -> TransferStatus.COMPLETED
+ FileReceiveManager.TransferStatus.FAILED -> TransferStatus.FAILED
+ FileReceiveManager.TransferStatus.CANCELLED -> TransferStatus.CANCELLED
+ },
+ elapsedTimeMs = state.elapsedTimeMs,
+ transferSpeed = state.transferSpeed
+ )
+ }
+
+ AnimatedVisibility(
+ visible = transfers.isNotEmpty(),
+ enter = fadeIn() + expandVertically(),
+ exit = fadeOut() + shrinkVertically()
+ ) {
+ Card(
+ modifier = modifier
+ .fillMaxWidth()
+ .animateContentSize(),
+ colors = CardDefaults.cardColors(
+ containerColor = MaterialTheme.colorScheme.secondaryContainer
+ )
+ ) {
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(16.dp),
+ verticalArrangement = Arrangement.spacedBy(12.dp)
+ ) {
+ // Header
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(8.dp)
+ ) {
+ Icon(
+ imageVector = Icons.Default.CloudDownload,
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.primary
+ )
+ Text(
+ text = "Receiving Files",
+ style = MaterialTheme.typography.titleMedium,
+ fontWeight = FontWeight.Bold
+ )
+ }
+
+ // Transfer items
+ transfers.forEach { transfer ->
+ FileTransferItem(transfer)
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun FileTransferItem(transfer: FileTransferInfo) {
+ Column(
+ modifier = Modifier.fillMaxWidth(),
+ verticalArrangement = Arrangement.spacedBy(4.dp)
+ ) {
+ // File name row
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Text(
+ text = transfer.fileName,
+ style = MaterialTheme.typography.bodyMedium,
+ fontWeight = FontWeight.Medium,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ modifier = Modifier.weight(1f)
+ )
+
+ // Status icon
+ when (transfer.status) {
+ TransferStatus.COMPLETED -> {
+ Icon(
+ imageVector = Icons.Default.CheckCircle,
+ contentDescription = "Completed",
+ tint = MaterialTheme.colorScheme.primary,
+ modifier = Modifier.size(20.dp)
+ )
+ }
+ TransferStatus.FAILED -> {
+ Icon(
+ imageVector = Icons.Default.Error,
+ contentDescription = "Failed",
+ tint = MaterialTheme.colorScheme.error,
+ modifier = Modifier.size(20.dp)
+ )
+ }
+ TransferStatus.CANCELLED -> {
+ Icon(
+ imageVector = Icons.Default.Cancel,
+ contentDescription = "Cancelled",
+ tint = MaterialTheme.colorScheme.outline,
+ modifier = Modifier.size(20.dp)
+ )
+ }
+ else -> {
+ Text(
+ text = "${(transfer.progress * 100).toInt()}%",
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.primary
+ )
+ }
+ }
+ }
+
+ // Progress info row
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ // Size info
+ Text(
+ text = "${formatFileSize(transfer.receivedSize)} / ${formatFileSize(transfer.totalSize)}",
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+
+ // Duration and speed
+ Row(
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ // Elapsed time
+ Text(
+ text = formatDuration(transfer.elapsedTimeMs),
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+
+ // Transfer speed (only when transferring)
+ if (transfer.status == TransferStatus.TRANSFERRING && transfer.transferSpeed > 0) {
+ Text(
+ text = "• ${formatSpeed(transfer.transferSpeed)}",
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.primary
+ )
+ }
+ }
+ }
+
+ // Progress bar
+ if (transfer.status == TransferStatus.TRANSFERRING) {
+ LinearProgressIndicator(
+ progress = { transfer.progress },
+ modifier = Modifier
+ .fillMaxWidth()
+ .height(4.dp),
+ color = MaterialTheme.colorScheme.primary,
+ trackColor = MaterialTheme.colorScheme.surfaceVariant
+ )
+ }
+ }
+}
+
+data class FileTransferInfo(
+ val id: String,
+ val fileName: String,
+ val totalSize: Long,
+ val receivedSize: Long,
+ val progress: Float,
+ val status: TransferStatus,
+ val elapsedTimeMs: Long = 0,
+ val transferSpeed: Long = 0 // bytes per second
+)
+
+enum class TransferStatus {
+ PENDING, TRANSFERRING, COMPLETED, FAILED, CANCELLED
+}
+
+private fun formatFileSize(bytes: Long): String {
+ return when {
+ bytes < 1024 -> "$bytes B"
+ bytes < 1024 * 1024 -> "${bytes / 1024} KB"
+ bytes < 1024 * 1024 * 1024 -> String.format("%.1f MB", bytes / (1024.0 * 1024.0))
+ else -> String.format("%.2f GB", bytes / (1024.0 * 1024.0 * 1024.0))
+ }
+}
+
+private fun formatDuration(ms: Long): String {
+ val seconds = ms / 1000
+ val minutes = seconds / 60
+ val hours = minutes / 60
+
+ return when {
+ hours > 0 -> String.format("%d:%02d:%02d", hours, minutes % 60, seconds % 60)
+ minutes > 0 -> String.format("%d:%02d", minutes, seconds % 60)
+ else -> String.format("0:%02d", seconds)
+ }
+}
+
+private fun formatSpeed(bytesPerSecond: Long): String {
+ val kbps = bytesPerSecond / 1024.0
+ val mbps = kbps / 1024.0
+
+ return when {
+ mbps >= 1 -> String.format("%.1f MB/s", mbps)
+ kbps >= 1 -> String.format("%.0f KB/s", kbps)
+ else -> "$bytesPerSecond B/s"
+ }
+}
diff --git a/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/dialogs/PermissionDialog.kt b/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/dialogs/PermissionDialog.kt
index c331ce2..0167dcf 100644
--- a/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/dialogs/PermissionDialog.kt
+++ b/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/dialogs/PermissionDialog.kt
@@ -21,7 +21,8 @@ enum class PermissionType {
WALLPAPER_ACCESS,
CALL_LOG,
CONTACTS,
- PHONE
+ PHONE,
+ ANSWER_PHONE_CALLS
}
data class PermissionInfo(
@@ -199,5 +200,13 @@ private fun getPermissionInfo(permissionType: PermissionType): PermissionInfo {
whyNeeded = "This permission allows AirSync to detect when your phone is ringing, when you answer, or when a call ends, so it can display a live call status on your Mac. \n\nAirSync NEVER accesses your call audio or records conversations. This is used solely to facilitate the remote call notification feature as a device companion.",
buttonText = "Grant Phone Access"
)
+
+ PermissionType.ANSWER_PHONE_CALLS -> PermissionInfo(
+ title = "Answer Calls",
+ icon = R.drawable.outline_call_end_24,
+ description = "AirSync needs this permission to end or reject calls from your Mac.",
+ whyNeeded = "To allow you to decline or hang up calls directly from your Mac, Android requires this specific permission. \n\nWithout it, the 'End Call' button on your Mac will not work. This permission is strictly used for call control actions you initiate.",
+ buttonText = "Grant Call Control"
+ )
}
}
diff --git a/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/dialogs/PermissionDialogs.kt b/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/dialogs/PermissionDialogs.kt
new file mode 100644
index 0000000..a4a2f42
--- /dev/null
+++ b/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/dialogs/PermissionDialogs.kt
@@ -0,0 +1 @@
+package com.sameerasw.airsync.presentation.ui.components.dialogs
diff --git a/app/src/main/java/com/sameerasw/airsync/presentation/ui/screens/AirSyncMainScreen.kt b/app/src/main/java/com/sameerasw/airsync/presentation/ui/screens/AirSyncMainScreen.kt
index 3a092a2..f0606f6 100644
--- a/app/src/main/java/com/sameerasw/airsync/presentation/ui/screens/AirSyncMainScreen.kt
+++ b/app/src/main/java/com/sameerasw/airsync/presentation/ui/screens/AirSyncMainScreen.kt
@@ -54,6 +54,7 @@ import kotlinx.coroutines.launch
import com.sameerasw.airsync.presentation.ui.components.cards.LastConnectedDeviceCard
import com.sameerasw.airsync.presentation.ui.components.cards.ManualConnectionCard
import com.sameerasw.airsync.presentation.ui.components.cards.ConnectionStatusCard
+import com.sameerasw.airsync.presentation.ui.components.cards.IncomingFileTransferCard
import com.sameerasw.airsync.presentation.ui.components.dialogs.AboutDialog
import com.sameerasw.airsync.presentation.ui.components.dialogs.ConnectionDialog
import com.sameerasw.airsync.presentation.ui.activities.QRScannerActivity
@@ -75,6 +76,8 @@ fun AirSyncMainScreen(
isPlus: Boolean = false,
symmetricKey: String? = null,
onNavigateToApps: () -> Unit = {},
+ onNavigateToHealth: () -> Unit = {},
+ onNavigateToFileTransfer: () -> Unit = {},
showAboutDialog: Boolean = false,
onDismissAbout: () -> Unit = {}
) {
@@ -586,6 +589,9 @@ fun AirSyncMainScreen(
uiState = uiState,
)
}
+
+ // Incoming File Transfer Card - shows when receiving files
+ IncomingFileTransferCard()
RoundedCardContainer{
AnimatedVisibility(
@@ -682,7 +688,9 @@ fun AirSyncMainScreen(
pendingExportJson = json
createDocLauncher.launch("airsync_settings_${System.currentTimeMillis()}.json")
},
- onImport = { openDocLauncher.launch(arrayOf("application/json")) }
+ onImport = { openDocLauncher.launch(arrayOf("application/json")) },
+ onNavigateToHealth = onNavigateToHealth,
+ onNavigateToFileTransfer = onNavigateToFileTransfer
)
}
}
@@ -703,7 +711,9 @@ fun AirSyncMainScreen(
pendingExportJson = json
createDocLauncher.launch("airsync_settings_${System.currentTimeMillis()}.json")
},
- onImport = { openDocLauncher.launch(arrayOf("application/json")) }
+ onImport = { openDocLauncher.launch(arrayOf("application/json")) },
+ onNavigateToHealth = onNavigateToHealth,
+ onNavigateToFileTransfer = onNavigateToFileTransfer
)
}
}
diff --git a/app/src/main/java/com/sameerasw/airsync/presentation/ui/screens/ClipboardScreen.kt b/app/src/main/java/com/sameerasw/airsync/presentation/ui/screens/ClipboardScreen.kt
index b863049..dc036a4 100644
--- a/app/src/main/java/com/sameerasw/airsync/presentation/ui/screens/ClipboardScreen.kt
+++ b/app/src/main/java/com/sameerasw/airsync/presentation/ui/screens/ClipboardScreen.kt
@@ -30,7 +30,6 @@ import java.text.SimpleDateFormat
import java.util.*
import kotlinx.coroutines.Job
-@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun ClipboardScreen(
clipboardHistory: List,
diff --git a/app/src/main/java/com/sameerasw/airsync/presentation/ui/screens/FileTransferScreen.kt b/app/src/main/java/com/sameerasw/airsync/presentation/ui/screens/FileTransferScreen.kt
new file mode 100644
index 0000000..ac35b59
--- /dev/null
+++ b/app/src/main/java/com/sameerasw/airsync/presentation/ui/screens/FileTransferScreen.kt
@@ -0,0 +1,395 @@
+package com.sameerasw.airsync.presentation.ui.screens
+
+import android.content.Context
+import android.net.Uri
+import androidx.activity.compose.rememberLauncherForActivityResult
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.compose.foundation.layout.*
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.ArrowBack
+import androidx.compose.material.icons.filled.AttachFile
+import androidx.compose.material.icons.filled.CheckCircle
+import androidx.compose.material.icons.filled.CloudUpload
+import androidx.compose.material.icons.filled.Error
+import androidx.compose.material.icons.filled.Schedule
+import androidx.compose.material.icons.filled.Warning
+import androidx.compose.material3.*
+import androidx.compose.runtime.*
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.dp
+import com.sameerasw.airsync.utils.FileTransferUtil
+import com.sameerasw.airsync.utils.WebSocketUtil
+import kotlinx.coroutines.launch
+
+data class TransferItem(
+ val id: String,
+ val name: String,
+ val size: Long,
+ val progress: Float,
+ val status: TransferStatus
+)
+
+enum class TransferStatus {
+ PENDING, TRANSFERRING, COMPLETED, FAILED
+}
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun FileTransferScreen(
+ onNavigateBack: () -> Unit
+) {
+ val context = LocalContext.current
+ val scope = rememberCoroutineScope()
+
+ var transferItems by remember { mutableStateOf>(emptyList()) }
+ var selectedFiles by remember { mutableStateOf>(emptyList()) }
+ var isConnected by remember { mutableStateOf(WebSocketUtil.isConnected()) }
+ var showError by remember { mutableStateOf(false) }
+ var errorMessage by remember { mutableStateOf("") }
+ var isSending by remember { mutableStateOf(false) }
+
+ // File picker launcher
+ val filePickerLauncher = rememberLauncherForActivityResult(
+ contract = ActivityResultContracts.GetMultipleContents()
+ ) { uris: List ->
+ if (uris.isNotEmpty()) {
+ selectedFiles = uris
+ android.util.Log.d("FileTransferScreen", "Selected ${uris.size} files")
+ }
+ }
+
+ // Function to send selected files
+ fun sendSelectedFiles() {
+ if (selectedFiles.isEmpty()) return
+
+ scope.launch {
+ if (!WebSocketUtil.isConnected()) {
+ errorMessage = "Not connected to Mac. Please connect first."
+ showError = true
+ return@launch
+ }
+
+ isSending = true
+
+ // Send files sequentially to avoid state conflicts
+ for (uri in selectedFiles) {
+ try {
+ val fileName = FileTransferUtil.getFileName(context, uri)
+ val fileSize = FileTransferUtil.getFileSize(context, uri)
+ val id = uri.toString()
+
+ // Add to transfer list on main thread
+ kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.Main) {
+ transferItems = transferItems + TransferItem(
+ id = id,
+ name = fileName,
+ size = fileSize,
+ progress = 0f,
+ status = TransferStatus.PENDING
+ )
+ }
+
+ // Send file
+ FileTransferUtil.sendFile(context, uri) { progress, status ->
+ // Update transfer item progress on main thread
+ scope.launch(kotlinx.coroutines.Dispatchers.Main) {
+ transferItems = transferItems.map { item ->
+ if (item.id == id) {
+ item.copy(progress = progress, status = status)
+ } else {
+ item
+ }
+ }
+ }
+ }
+ } catch (e: Exception) {
+ kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.Main) {
+ errorMessage = "Failed to send file: ${e.message}"
+ showError = true
+ }
+ android.util.Log.e("FileTransferScreen", "Error sending file", e)
+ }
+ }
+
+ // Clear selected files after sending
+ kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.Main) {
+ selectedFiles = emptyList()
+ isSending = false
+ }
+ }
+ }
+
+ // Check connection status periodically
+ LaunchedEffect(Unit) {
+ while (true) {
+ isConnected = WebSocketUtil.isConnected()
+ kotlinx.coroutines.delay(1000)
+ }
+ }
+
+ Scaffold(
+ topBar = {
+ TopAppBar(
+ title = { Text("File Transfer") },
+ navigationIcon = {
+ IconButton(onClick = onNavigateBack) {
+ Icon(Icons.Default.ArrowBack, "Back")
+ }
+ }
+ )
+ }
+ ) { padding ->
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(padding)
+ .padding(16.dp),
+ verticalArrangement = Arrangement.spacedBy(16.dp)
+ ) {
+ // Connection status card
+ Card(
+ modifier = Modifier.fillMaxWidth(),
+ colors = CardDefaults.cardColors(
+ containerColor = if (isConnected)
+ MaterialTheme.colorScheme.primaryContainer
+ else
+ MaterialTheme.colorScheme.errorContainer
+ )
+ ) {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(16.dp),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Row(
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Icon(
+ if (isConnected) Icons.Default.CheckCircle else Icons.Default.Warning,
+ contentDescription = null
+ )
+ Text(
+ if (isConnected) "Connected to Mac" else "Not Connected",
+ style = MaterialTheme.typography.titleMedium,
+ fontWeight = FontWeight.Bold
+ )
+ }
+ }
+ }
+
+ // Transfer options
+ Text(
+ "Send to Mac",
+ style = MaterialTheme.typography.titleLarge,
+ fontWeight = FontWeight.Bold
+ )
+
+ Button(
+ onClick = { filePickerLauncher.launch("*/*") },
+ modifier = Modifier.fillMaxWidth(),
+ enabled = isConnected && !isSending
+ ) {
+ Icon(Icons.Default.AttachFile, contentDescription = null)
+ Spacer(modifier = Modifier.width(8.dp))
+ Text("Select Files")
+ }
+
+ // Show selected files
+ if (selectedFiles.isNotEmpty()) {
+ Card(
+ modifier = Modifier.fillMaxWidth(),
+ colors = CardDefaults.cardColors(
+ containerColor = MaterialTheme.colorScheme.secondaryContainer
+ )
+ ) {
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(16.dp),
+ verticalArrangement = Arrangement.spacedBy(8.dp)
+ ) {
+ Text(
+ "${selectedFiles.size} file(s) selected",
+ style = MaterialTheme.typography.titleMedium,
+ fontWeight = FontWeight.Bold
+ )
+
+ selectedFiles.take(3).forEach { uri ->
+ Text(
+ "• ${FileTransferUtil.getFileName(context, uri)}",
+ style = MaterialTheme.typography.bodyMedium
+ )
+ }
+
+ if (selectedFiles.size > 3) {
+ Text(
+ "... and ${selectedFiles.size - 3} more",
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
+
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.spacedBy(8.dp)
+ ) {
+ Button(
+ onClick = { sendSelectedFiles() },
+ modifier = Modifier.weight(1f),
+ enabled = !isSending
+ ) {
+ if (isSending) {
+ CircularProgressIndicator(
+ modifier = Modifier.size(16.dp),
+ strokeWidth = 2.dp
+ )
+ Spacer(modifier = Modifier.width(8.dp))
+ }
+ Text(if (isSending) "Sending..." else "Send Now")
+ }
+
+ OutlinedButton(
+ onClick = { selectedFiles = emptyList() },
+ modifier = Modifier.weight(1f),
+ enabled = !isSending
+ ) {
+ Text("Cancel")
+ }
+ }
+ }
+ }
+ }
+
+ if (!isConnected) {
+ Card(
+ modifier = Modifier.fillMaxWidth(),
+ colors = CardDefaults.cardColors(
+ containerColor = MaterialTheme.colorScheme.secondaryContainer
+ )
+ ) {
+ Column(
+ modifier = Modifier.padding(16.dp),
+ verticalArrangement = Arrangement.spacedBy(8.dp)
+ ) {
+ Text(
+ "Connection Required",
+ style = MaterialTheme.typography.titleMedium,
+ fontWeight = FontWeight.Bold
+ )
+ Text(
+ "Please connect to your Mac from the main screen to enable file transfer.",
+ style = MaterialTheme.typography.bodyMedium
+ )
+ }
+ }
+ }
+
+ // Transfer list
+ if (transferItems.isNotEmpty()) {
+ Text(
+ "Transfers",
+ style = MaterialTheme.typography.titleMedium,
+ fontWeight = FontWeight.Bold
+ )
+
+ LazyColumn(
+ verticalArrangement = Arrangement.spacedBy(8.dp)
+ ) {
+ items(transferItems) { item ->
+ TransferItemCard(item)
+ }
+ }
+ }
+ }
+
+ // Error dialog
+ if (showError) {
+ AlertDialog(
+ onDismissRequest = { showError = false },
+ title = { Text("Error") },
+ text = { Text(errorMessage) },
+ confirmButton = {
+ TextButton(onClick = { showError = false }) {
+ Text("OK")
+ }
+ }
+ )
+ }
+ }
+}
+
+@Composable
+private fun TransferItemCard(item: TransferItem) {
+ Card(
+ modifier = Modifier.fillMaxWidth()
+ ) {
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(16.dp),
+ verticalArrangement = Arrangement.spacedBy(8.dp)
+ ) {
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Column(modifier = Modifier.weight(1f)) {
+ Text(
+ item.name,
+ style = MaterialTheme.typography.bodyLarge,
+ fontWeight = FontWeight.Medium
+ )
+ Text(
+ formatFileSize(item.size),
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
+
+ Icon(
+ when (item.status) {
+ TransferStatus.PENDING -> Icons.Default.Schedule
+ TransferStatus.TRANSFERRING -> Icons.Default.CloudUpload
+ TransferStatus.COMPLETED -> Icons.Default.CheckCircle
+ TransferStatus.FAILED -> Icons.Default.Error
+ },
+ contentDescription = null,
+ tint = when (item.status) {
+ TransferStatus.COMPLETED -> MaterialTheme.colorScheme.primary
+ TransferStatus.FAILED -> MaterialTheme.colorScheme.error
+ else -> MaterialTheme.colorScheme.onSurfaceVariant
+ }
+ )
+ }
+
+ if (item.status == TransferStatus.TRANSFERRING) {
+ LinearProgressIndicator(
+ progress = { item.progress },
+ modifier = Modifier.fillMaxWidth()
+ )
+ Text(
+ "${(item.progress * 100).toInt()}%",
+ style = MaterialTheme.typography.bodySmall
+ )
+ }
+ }
+ }
+}
+
+private fun formatFileSize(bytes: Long): String {
+ return when {
+ bytes < 1024 -> "$bytes B"
+ bytes < 1024 * 1024 -> "${bytes / 1024} KB"
+ bytes < 1024 * 1024 * 1024 -> "${bytes / (1024 * 1024)} MB"
+ else -> "${bytes / (1024 * 1024 * 1024)} GB"
+ }
+}
diff --git a/app/src/main/java/com/sameerasw/airsync/presentation/ui/screens/HealthDataScreen.kt b/app/src/main/java/com/sameerasw/airsync/presentation/ui/screens/HealthDataScreen.kt
new file mode 100644
index 0000000..c1521b2
--- /dev/null
+++ b/app/src/main/java/com/sameerasw/airsync/presentation/ui/screens/HealthDataScreen.kt
@@ -0,0 +1,406 @@
+package com.sameerasw.airsync.presentation.ui.screens
+
+import androidx.activity.compose.rememberLauncherForActivityResult
+import androidx.compose.foundation.layout.*
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.*
+import androidx.compose.material3.*
+import androidx.compose.runtime.*
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.dp
+import androidx.health.connect.client.HealthConnectClient
+import androidx.health.connect.client.PermissionController
+import androidx.health.connect.client.permission.HealthPermission
+import androidx.health.connect.client.records.*
+import com.sameerasw.airsync.utils.HealthConnectUtil
+import kotlinx.coroutines.launch
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun HealthDataScreen(
+ onNavigateBack: () -> Unit
+) {
+ val context = LocalContext.current
+ val scope = rememberCoroutineScope()
+
+ var isHealthConnectAvailable by remember { mutableStateOf(false) }
+ var hasPermissions by remember { mutableStateOf(false) }
+ var healthSummary by remember { mutableStateOf(null) }
+ var isLoading by remember { mutableStateOf(false) }
+ var errorMessage by remember { mutableStateOf(null) }
+
+ // Health Connect permission launcher
+ val permissionLauncher = rememberLauncherForActivityResult(
+ contract = PermissionController.createRequestPermissionResultContract()
+ ) { granted ->
+ scope.launch {
+ hasPermissions = HealthConnectUtil.hasPermissions(context)
+ if (hasPermissions) {
+ loadHealthData(context) { summary, error ->
+ healthSummary = summary
+ errorMessage = error
+ isLoading = false
+ }
+ }
+ }
+ }
+
+ // Check Health Connect availability and permissions
+ LaunchedEffect(Unit) {
+ isHealthConnectAvailable = HealthConnectUtil.isAvailable(context)
+ if (isHealthConnectAvailable) {
+ hasPermissions = HealthConnectUtil.hasPermissions(context)
+ if (hasPermissions) {
+ isLoading = true
+ loadHealthData(context) { summary, error ->
+ healthSummary = summary
+ errorMessage = error
+ isLoading = false
+ }
+ }
+ }
+ }
+
+ Scaffold(
+ topBar = {
+ TopAppBar(
+ title = { Text("Health & Fitness") },
+ navigationIcon = {
+ IconButton(onClick = onNavigateBack) {
+ Icon(Icons.Default.ArrowBack, contentDescription = "Back")
+ }
+ }
+ )
+ }
+ ) { paddingValues ->
+ LazyColumn(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(paddingValues),
+ contentPadding = PaddingValues(16.dp),
+ verticalArrangement = Arrangement.spacedBy(16.dp)
+ ) {
+ // Health Connect Status Card
+ item {
+ HealthConnectStatusCard(
+ isAvailable = isHealthConnectAvailable,
+ hasPermissions = hasPermissions,
+ onRequestPermissions = {
+ val permissions = HealthConnectUtil.PERMISSIONS
+ permissionLauncher.launch(permissions)
+ }
+ )
+ }
+
+ if (hasPermissions && healthSummary != null) {
+ // Today's Summary
+ item {
+ TodaySummaryCard(healthSummary!!)
+ }
+
+ // Activity Metrics
+ item {
+ ActivityMetricsCard(healthSummary!!)
+ }
+
+ // Health Metrics
+ item {
+ HealthMetricsCard(healthSummary!!)
+ }
+ }
+
+ if (isLoading) {
+ item {
+ Box(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(32.dp),
+ contentAlignment = Alignment.Center
+ ) {
+ CircularProgressIndicator()
+ }
+ }
+ }
+
+ if (errorMessage != null) {
+ item {
+ Card(
+ colors = CardDefaults.cardColors(
+ containerColor = MaterialTheme.colorScheme.errorContainer
+ )
+ ) {
+ Text(
+ text = errorMessage!!,
+ modifier = Modifier.padding(16.dp),
+ color = MaterialTheme.colorScheme.onErrorContainer
+ )
+ }
+ }
+ }
+ }
+ }
+}
+
+@Composable
+fun HealthConnectStatusCard(
+ isAvailable: Boolean,
+ hasPermissions: Boolean,
+ onRequestPermissions: () -> Unit
+) {
+ Card(
+ colors = CardDefaults.cardColors(
+ containerColor = if (!isAvailable) {
+ MaterialTheme.colorScheme.errorContainer
+ } else if (!hasPermissions) {
+ MaterialTheme.colorScheme.secondaryContainer
+ } else {
+ MaterialTheme.colorScheme.primaryContainer
+ }
+ )
+ ) {
+ Column(
+ modifier = Modifier.padding(16.dp)
+ ) {
+ Row(
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Icon(
+ imageVector = if (hasPermissions) Icons.Default.CheckCircle else Icons.Default.FavoriteBorder,
+ contentDescription = null,
+ modifier = Modifier.size(32.dp)
+ )
+
+ Spacer(modifier = Modifier.width(12.dp))
+
+ Column(modifier = Modifier.weight(1f)) {
+ Text(
+ text = if (!isAvailable) {
+ "Health Connect Not Available"
+ } else if (!hasPermissions) {
+ "Permissions Needed"
+ } else {
+ "Connected to Health Connect"
+ },
+ style = MaterialTheme.typography.titleMedium,
+ fontWeight = FontWeight.Bold
+ )
+
+ Text(
+ text = if (!isAvailable) {
+ "Install Health Connect from Play Store"
+ } else if (!hasPermissions) {
+ "Grant permissions to sync health data"
+ } else {
+ "Syncing data from health apps"
+ },
+ style = MaterialTheme.typography.bodySmall
+ )
+ }
+ }
+
+ if (isAvailable && !hasPermissions) {
+ Spacer(modifier = Modifier.height(12.dp))
+ Button(
+ onClick = onRequestPermissions,
+ modifier = Modifier.fillMaxWidth()
+ ) {
+ Icon(Icons.Default.Security, contentDescription = null)
+ Spacer(modifier = Modifier.width(8.dp))
+ Text("Grant Permissions")
+ }
+ }
+ }
+ }
+}
+
+@Composable
+fun TodaySummaryCard(summary: com.sameerasw.airsync.models.HealthSummary) {
+ Card {
+ Column(
+ modifier = Modifier.padding(16.dp)
+ ) {
+ Text(
+ text = "Today's Summary",
+ style = MaterialTheme.typography.titleLarge,
+ fontWeight = FontWeight.Bold
+ )
+
+ Spacer(modifier = Modifier.height(16.dp))
+
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.SpaceEvenly
+ ) {
+ SummaryItem(
+ icon = Icons.Default.DirectionsWalk,
+ value = summary.steps?.toString() ?: "—",
+ label = "Steps",
+ color = MaterialTheme.colorScheme.primary
+ )
+
+ SummaryItem(
+ icon = Icons.Default.LocalFireDepartment,
+ value = summary.calories?.toString() ?: "—",
+ label = "Calories",
+ color = MaterialTheme.colorScheme.tertiary
+ )
+
+ SummaryItem(
+ icon = Icons.Default.FavoriteBorder,
+ value = summary.heartRateAvg?.toString() ?: "—",
+ label = "Heart Rate",
+ color = MaterialTheme.colorScheme.error
+ )
+ }
+ }
+ }
+}
+
+@Composable
+fun ActivityMetricsCard(summary: com.sameerasw.airsync.models.HealthSummary) {
+ Card {
+ Column(
+ modifier = Modifier.padding(16.dp)
+ ) {
+ Text(
+ text = "Activity",
+ style = MaterialTheme.typography.titleMedium,
+ fontWeight = FontWeight.Bold
+ )
+
+ Spacer(modifier = Modifier.height(12.dp))
+
+ MetricRow(
+ icon = Icons.Default.DirectionsRun,
+ label = "Distance",
+ value = summary.distance?.let { "%.2f km".format(it) } ?: "—"
+ )
+
+ MetricRow(
+ icon = Icons.Default.Timer,
+ label = "Active Minutes",
+ value = summary.activeMinutes?.let { "$it min" } ?: "—"
+ )
+ }
+ }
+}
+
+@Composable
+fun HealthMetricsCard(summary: com.sameerasw.airsync.models.HealthSummary) {
+ Card {
+ Column(
+ modifier = Modifier.padding(16.dp)
+ ) {
+ Text(
+ text = "Health",
+ style = MaterialTheme.typography.titleMedium,
+ fontWeight = FontWeight.Bold
+ )
+
+ Spacer(modifier = Modifier.height(12.dp))
+
+ MetricRow(
+ icon = Icons.Default.Bedtime,
+ label = "Sleep",
+ value = summary.sleepDuration?.let {
+ val hours = it / 60
+ val minutes = it % 60
+ "${hours}h ${minutes}m"
+ } ?: "—"
+ )
+
+ if (summary.heartRateMin != null && summary.heartRateMax != null) {
+ MetricRow(
+ icon = Icons.Default.FavoriteBorder,
+ label = "Heart Rate Range",
+ value = "${summary.heartRateMin} - ${summary.heartRateMax} bpm"
+ )
+ }
+ }
+ }
+}
+
+@Composable
+fun SummaryItem(
+ icon: androidx.compose.ui.graphics.vector.ImageVector,
+ value: String,
+ label: String,
+ color: androidx.compose.ui.graphics.Color
+) {
+ Column(
+ horizontalAlignment = Alignment.CenterHorizontally
+ ) {
+ Icon(
+ imageVector = icon,
+ contentDescription = null,
+ modifier = Modifier.size(32.dp),
+ tint = color
+ )
+
+ Spacer(modifier = Modifier.height(8.dp))
+
+ Text(
+ text = value,
+ style = MaterialTheme.typography.headlineSmall,
+ fontWeight = FontWeight.Bold
+ )
+
+ Text(
+ text = label,
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
+}
+
+@Composable
+fun MetricRow(
+ icon: androidx.compose.ui.graphics.vector.ImageVector,
+ label: String,
+ value: String
+) {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(vertical = 8.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Icon(
+ imageVector = icon,
+ contentDescription = null,
+ modifier = Modifier.size(24.dp),
+ tint = MaterialTheme.colorScheme.primary
+ )
+
+ Spacer(modifier = Modifier.width(12.dp))
+
+ Text(
+ text = label,
+ modifier = Modifier.weight(1f),
+ style = MaterialTheme.typography.bodyMedium
+ )
+
+ Text(
+ text = value,
+ style = MaterialTheme.typography.bodyMedium,
+ fontWeight = FontWeight.Bold
+ )
+ }
+}
+
+private suspend fun loadHealthData(
+ context: android.content.Context,
+ onResult: (com.sameerasw.airsync.models.HealthSummary?, String?) -> Unit
+) {
+ try {
+ val summary = HealthConnectUtil.getTodaySummary(context)
+ onResult(summary, null)
+ } catch (e: Exception) {
+ onResult(null, "Error loading health data: ${e.message}")
+ }
+}
diff --git a/app/src/main/java/com/sameerasw/airsync/presentation/ui/screens/PermissionsScreen.kt b/app/src/main/java/com/sameerasw/airsync/presentation/ui/screens/PermissionsScreen.kt
index d466e49..e95f7ea 100644
--- a/app/src/main/java/com/sameerasw/airsync/presentation/ui/screens/PermissionsScreen.kt
+++ b/app/src/main/java/com/sameerasw/airsync/presentation/ui/screens/PermissionsScreen.kt
@@ -26,6 +26,7 @@ fun PermissionsScreen(
onRequestCallLogPermission: (() -> Unit)? = null,
onRequestContactsPermission: (() -> Unit)? = null,
onRequestPhonePermission: (() -> Unit)? = null,
+ onRequestAnswerCallPermission: (() -> Unit)? = null,
refreshTrigger: Int = 0
) {
val context = LocalContext.current
@@ -199,6 +200,15 @@ fun PermissionsScreen(
isCritical = false
)
}
+
+ "Answer Calls" -> {
+ PermissionButton(
+ permissionName = permission,
+ description = "Enables ending calls from Mac",
+ onExplainClick = { showDialog = PermissionType.ANSWER_PHONE_CALLS },
+ isCritical = false
+ )
+ }
}
}
}
@@ -237,6 +247,9 @@ fun PermissionsScreen(
PermissionType.PHONE -> {
onRequestPhonePermission?.invoke()
}
+ PermissionType.ANSWER_PHONE_CALLS -> {
+ onRequestAnswerCallPermission?.invoke()
+ }
}
}
)
diff --git a/app/src/main/java/com/sameerasw/airsync/presentation/viewmodel/PermissionsViewModel.kt b/app/src/main/java/com/sameerasw/airsync/presentation/viewmodel/PermissionsViewModel.kt
new file mode 100644
index 0000000..2193f84
--- /dev/null
+++ b/app/src/main/java/com/sameerasw/airsync/presentation/viewmodel/PermissionsViewModel.kt
@@ -0,0 +1,50 @@
+package com.sameerasw.airsync.presentation.viewmodel
+
+import android.app.Application
+import androidx.lifecycle.AndroidViewModel
+import androidx.lifecycle.viewModelScope
+import com.sameerasw.airsync.models.PermissionGroup
+import com.sameerasw.airsync.utils.PermissionUtil
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.launch
+
+class PermissionsViewModel(application: Application) : AndroidViewModel(application) {
+
+ private val _permissionGroups = MutableStateFlow>(emptyList())
+ val permissionGroups: StateFlow> = _permissionGroups.asStateFlow()
+
+ private val _missingCount = MutableStateFlow(0)
+ val missingCount: StateFlow = _missingCount.asStateFlow()
+
+ private val _missingRequiredCount = MutableStateFlow(0)
+ val missingRequiredCount: StateFlow = _missingRequiredCount.asStateFlow()
+
+ init {
+ refreshPermissions()
+ }
+
+ fun refreshPermissions() {
+ viewModelScope.launch {
+ val groups = PermissionUtil.getAllPermissionGroups(getApplication())
+ _permissionGroups.value = groups
+
+ val allPermissions = groups.flatMap { it.permissions }
+ _missingCount.value = allPermissions.count { !it.isGranted }
+ _missingRequiredCount.value = allPermissions.count { !it.isGranted && it.isRequired }
+ }
+ }
+
+ fun getMissingPermissionCount(): Int {
+ return _missingCount.value
+ }
+
+ fun getMissingRequiredPermissionCount(): Int {
+ return _missingRequiredCount.value
+ }
+
+ fun hasAllRequiredPermissions(): Boolean {
+ return _missingRequiredCount.value == 0
+ }
+}
diff --git a/app/src/main/java/com/sameerasw/airsync/receiver/MirrorRequestReceiver.kt b/app/src/main/java/com/sameerasw/airsync/receiver/MirrorRequestReceiver.kt
new file mode 100644
index 0000000..25de012
--- /dev/null
+++ b/app/src/main/java/com/sameerasw/airsync/receiver/MirrorRequestReceiver.kt
@@ -0,0 +1,18 @@
+package com.sameerasw.airsync.receiver
+
+import android.content.BroadcastReceiver
+import android.content.Context
+import android.content.Intent
+import com.sameerasw.airsync.presentation.ui.activities.ScreenShareActivity
+
+class MirrorRequestReceiver : BroadcastReceiver() {
+ override fun onReceive(context: Context, intent: Intent) {
+ if (intent.action == "com.sameerasw.airsync.MIRROR_REQUEST") {
+ val newIntent = Intent(context, ScreenShareActivity::class.java).apply {
+ putExtras(intent.extras!!)
+ addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
+ }
+ context.startActivity(newIntent)
+ }
+ }
+}
diff --git a/app/src/main/java/com/sameerasw/airsync/receiver/PhoneStateReceiver.kt b/app/src/main/java/com/sameerasw/airsync/receiver/PhoneStateReceiver.kt
new file mode 100644
index 0000000..53f5176
--- /dev/null
+++ b/app/src/main/java/com/sameerasw/airsync/receiver/PhoneStateReceiver.kt
@@ -0,0 +1,160 @@
+package com.sameerasw.airsync.receiver
+
+import android.content.BroadcastReceiver
+import android.content.Context
+import android.content.Intent
+import android.telephony.TelephonyManager
+import android.util.Log
+import android.net.Uri
+import android.provider.ContactsContract
+import com.sameerasw.airsync.models.CallState
+import com.sameerasw.airsync.models.OngoingCall
+import com.sameerasw.airsync.service.LiveNotificationService
+import com.sameerasw.airsync.utils.JsonUtil
+import com.sameerasw.airsync.utils.WebSocketUtil
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.launch
+import java.util.UUID
+
+class PhoneStateReceiver : BroadcastReceiver() {
+ companion object {
+ private const val TAG = "PhoneStateReceiver"
+ private var currentCall: OngoingCall? = null
+ private var callStartTime: Long = 0
+ }
+
+ override fun onReceive(context: Context, intent: Intent) {
+ if (intent.action != TelephonyManager.ACTION_PHONE_STATE_CHANGED) {
+ return
+ }
+
+ val state = intent.getStringExtra(TelephonyManager.EXTRA_STATE)
+ val incomingNumber = intent.getStringExtra(TelephonyManager.EXTRA_INCOMING_NUMBER)
+
+ Log.d(TAG, "Phone state changed: $state, number: $incomingNumber")
+
+ when (state) {
+ TelephonyManager.EXTRA_STATE_RINGING -> {
+ // Incoming call ringing
+ handleRinging(context, incomingNumber)
+ }
+ TelephonyManager.EXTRA_STATE_OFFHOOK -> {
+ // Call answered or outgoing call started
+ handleOffHook(context, incomingNumber)
+ }
+ TelephonyManager.EXTRA_STATE_IDLE -> {
+ // Call ended
+ handleIdle(context)
+ }
+ }
+ }
+
+ private fun handleRinging(context: Context, number: String?) {
+ if (number == null) return
+
+ val contactName = getContactName(context, number)
+ val call = OngoingCall(
+ id = UUID.randomUUID().toString(),
+ number = number,
+ contactName = contactName,
+ state = CallState.RINGING,
+ startTime = System.currentTimeMillis(),
+ isIncoming = true
+ )
+
+ currentCall = call
+ callStartTime = System.currentTimeMillis()
+
+ // Send to Mac
+ sendCallNotification(context, call)
+ }
+
+ private fun handleOffHook(context: Context, number: String?) {
+ val call = currentCall
+ if (call != null) {
+ // Update existing call to active
+ val updatedCall = call.copy(state = CallState.ACTIVE)
+ currentCall = updatedCall
+ sendCallNotification(context, updatedCall)
+ } else if (number != null) {
+ // Outgoing call
+ val contactName = getContactName(context, number)
+ val newCall = OngoingCall(
+ id = UUID.randomUUID().toString(),
+ number = number,
+ contactName = contactName,
+ state = CallState.ACTIVE,
+ startTime = System.currentTimeMillis(),
+ isIncoming = false
+ )
+ currentCall = newCall
+ callStartTime = System.currentTimeMillis()
+ sendCallNotification(context, newCall)
+ }
+ }
+
+ private fun handleIdle(context: Context) {
+ val call = currentCall
+ if (call != null) {
+ val duration = (System.currentTimeMillis() - callStartTime) / 1000
+ val endedCall = call.copy(state = CallState.DISCONNECTED)
+ sendCallNotification(context, endedCall)
+ currentCall = null
+ callStartTime = 0
+
+ // Sync updated call logs to macOS after call ends
+ CoroutineScope(Dispatchers.IO).launch {
+ kotlinx.coroutines.delay(1000) // Wait for call log to be written to database
+ com.sameerasw.airsync.utils.SyncManager.syncDataToMac(context)
+ }
+ }
+ }
+
+ private fun sendCallNotification(context: Context, call: OngoingCall) {
+ // Only send call notifications when connected to Mac
+ if (!WebSocketUtil.isConnected()) {
+ Log.d(TAG, "Skipping call notification - not connected to Mac (state: ${call.state})")
+ return
+ }
+
+ CoroutineScope(Dispatchers.IO).launch {
+ try {
+ // Send to Mac via WebSocket - Mac will show the notification
+ val json = JsonUtil.createCallNotificationJson(call)
+ val sent = WebSocketUtil.sendMessage(json)
+ if (sent) {
+ Log.d(TAG, "Call notification sent to Mac: ${call.state}")
+ } else {
+ Log.w(TAG, "Failed to send call notification to Mac")
+ }
+
+ // Note: We don't show call notifications on Android
+ // The Mac app handles displaying call notifications to the user
+ } catch (e: Exception) {
+ Log.e(TAG, "Error sending call notification", e)
+ }
+ }
+ }
+
+ private fun getContactName(context: Context, phoneNumber: String): String? {
+ val uri = Uri.withAppendedPath(
+ ContactsContract.PhoneLookup.CONTENT_FILTER_URI,
+ Uri.encode(phoneNumber)
+ )
+
+ val projection = arrayOf(ContactsContract.PhoneLookup.DISPLAY_NAME)
+
+ try {
+ context.contentResolver.query(uri, projection, null, null, null)?.use { cursor ->
+ if (cursor.moveToFirst()) {
+ return cursor.getString(cursor.getColumnIndexOrThrow(ContactsContract.PhoneLookup.DISPLAY_NAME))
+ }
+ }
+ } catch (e: Exception) {
+ Log.e(TAG, "Error getting contact name", e)
+ }
+
+ return null
+ }
+}
diff --git a/app/src/main/java/com/sameerasw/airsync/receiver/SmsReceiver.kt b/app/src/main/java/com/sameerasw/airsync/receiver/SmsReceiver.kt
new file mode 100644
index 0000000..1c9383d
--- /dev/null
+++ b/app/src/main/java/com/sameerasw/airsync/receiver/SmsReceiver.kt
@@ -0,0 +1,95 @@
+package com.sameerasw.airsync.receiver
+
+import android.content.BroadcastReceiver
+import android.content.Context
+import android.content.Intent
+import android.net.Uri
+import android.provider.ContactsContract
+import android.provider.Telephony
+import android.util.Log
+import com.sameerasw.airsync.models.SmsMessage
+import com.sameerasw.airsync.utils.JsonUtil
+import com.sameerasw.airsync.utils.WebSocketUtil
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.launch
+
+class SmsReceiver : BroadcastReceiver() {
+ companion object {
+ private const val TAG = "SmsReceiver"
+ }
+
+ override fun onReceive(context: Context, intent: Intent) {
+ if (intent.action != Telephony.Sms.Intents.SMS_RECEIVED_ACTION) {
+ return
+ }
+
+ val messages = Telephony.Sms.Intents.getMessagesFromIntent(intent)
+ if (messages.isEmpty()) {
+ return
+ }
+
+ for (smsMessage in messages) {
+ val address = smsMessage.originatingAddress ?: continue
+ val body = smsMessage.messageBody ?: continue
+ val timestamp = smsMessage.timestampMillis
+
+ Log.d(TAG, "SMS received from $address")
+
+ // Get contact name
+ val contactName = getContactName(context, address)
+
+ // Create SMS message object
+ val message = SmsMessage(
+ id = timestamp.toString(),
+ threadId = "", // Will be populated when queried from database
+ address = address,
+ body = body,
+ date = timestamp,
+ type = 1, // Received
+ read = false,
+ contactName = contactName
+ )
+
+ // Send to Mac
+ sendSmsNotification(context, message)
+ }
+ }
+
+ private fun sendSmsNotification(context: Context, message: SmsMessage) {
+ CoroutineScope(Dispatchers.IO).launch {
+ try {
+ val json = JsonUtil.createSmsNotificationJson(message)
+ WebSocketUtil.sendMessage(json)
+ Log.d(TAG, "SMS notification sent")
+
+ // Also sync updated SMS threads to macOS
+ kotlinx.coroutines.delay(500) // Wait for SMS to be written to database
+ com.sameerasw.airsync.utils.SyncManager.syncDataToMac(context)
+ } catch (e: Exception) {
+ Log.e(TAG, "Error sending SMS notification", e)
+ }
+ }
+ }
+
+ private fun getContactName(context: Context, phoneNumber: String): String? {
+ val uri = Uri.withAppendedPath(
+ ContactsContract.PhoneLookup.CONTENT_FILTER_URI,
+ Uri.encode(phoneNumber)
+ )
+
+ val projection = arrayOf(ContactsContract.PhoneLookup.DISPLAY_NAME)
+
+ try {
+ context.contentResolver.query(uri, projection, null, null, null)?.use { cursor ->
+ if (cursor.moveToFirst()) {
+ return cursor.getString(cursor.getColumnIndexOrThrow(ContactsContract.PhoneLookup.DISPLAY_NAME))
+ }
+ }
+ } catch (e: Exception) {
+ Log.e(TAG, "Error getting contact name", e)
+ }
+
+ return null
+ }
+}
diff --git a/app/src/main/java/com/sameerasw/airsync/service/AudioCaptureService.kt b/app/src/main/java/com/sameerasw/airsync/service/AudioCaptureService.kt
new file mode 100644
index 0000000..1a2a580
--- /dev/null
+++ b/app/src/main/java/com/sameerasw/airsync/service/AudioCaptureService.kt
@@ -0,0 +1,223 @@
+package com.sameerasw.airsync.service
+
+import android.Manifest
+import android.content.Context
+import android.content.pm.PackageManager
+import android.media.AudioAttributes
+import android.media.AudioFormat
+import android.media.AudioPlaybackCaptureConfiguration
+import android.media.AudioRecord
+import android.media.projection.MediaProjection
+import android.os.Build
+import android.util.Base64
+import android.util.Log
+import androidx.annotation.RequiresApi
+import androidx.core.content.ContextCompat
+import com.sameerasw.airsync.utils.WebSocketUtil
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.isActive
+import kotlinx.coroutines.launch
+
+/**
+ * Audio capture service for mirroring Android audio to Mac.
+ * Requires Android 10+ (API 29) for AudioPlaybackCapture API.
+ */
+class AudioCaptureService(
+ private val context: Context,
+ private val mediaProjection: MediaProjection
+) {
+ companion object {
+ private const val TAG = "AudioCaptureService"
+
+ // Audio configuration
+ const val SAMPLE_RATE = 44100
+ const val CHANNEL_CONFIG = AudioFormat.CHANNEL_IN_STEREO
+ const val AUDIO_FORMAT = AudioFormat.ENCODING_PCM_16BIT
+ const val CHANNELS = 2
+ const val BITS_PER_SAMPLE = 16
+
+ // Buffer size for ~50ms of audio
+ private val BUFFER_SIZE = AudioRecord.getMinBufferSize(SAMPLE_RATE, CHANNEL_CONFIG, AUDIO_FORMAT)
+ .coerceAtLeast(SAMPLE_RATE * CHANNELS * (BITS_PER_SAMPLE / 8) / 20) // At least 50ms
+ }
+
+ private var audioRecord: AudioRecord? = null
+ private var captureJob: Job? = null
+ private var isCapturing = false
+
+ // Throttling to prevent flooding WebSocket
+ private var lastFrameSentTime = 0L
+ private val minFrameIntervalMs = 40L // ~25 frames per second max
+
+ /**
+ * Check if audio capture is supported on this device
+ */
+ fun isSupported(): Boolean {
+ return Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q
+ }
+
+ /**
+ * Start capturing audio from the device
+ */
+ @RequiresApi(Build.VERSION_CODES.Q)
+ fun startCapture(): Boolean {
+ if (!isSupported()) {
+ Log.e(TAG, "Audio capture not supported on this Android version (requires API 29+)")
+ return false
+ }
+
+ if (isCapturing) {
+ Log.w(TAG, "Audio capture already running")
+ return true
+ }
+
+ // Check for RECORD_AUDIO permission
+ if (ContextCompat.checkSelfPermission(context, Manifest.permission.RECORD_AUDIO)
+ != PackageManager.PERMISSION_GRANTED) {
+ Log.e(TAG, "RECORD_AUDIO permission not granted")
+ return false
+ }
+
+ try {
+ // Create audio playback capture configuration
+ val config = AudioPlaybackCaptureConfiguration.Builder(mediaProjection)
+ .addMatchingUsage(AudioAttributes.USAGE_MEDIA)
+ .addMatchingUsage(AudioAttributes.USAGE_GAME)
+ .addMatchingUsage(AudioAttributes.USAGE_UNKNOWN)
+ .build()
+
+ // Create AudioRecord with playback capture
+ val audioFormat = AudioFormat.Builder()
+ .setEncoding(AUDIO_FORMAT)
+ .setSampleRate(SAMPLE_RATE)
+ .setChannelMask(CHANNEL_CONFIG)
+ .build()
+
+ audioRecord = AudioRecord.Builder()
+ .setAudioPlaybackCaptureConfig(config)
+ .setAudioFormat(audioFormat)
+ .setBufferSizeInBytes(BUFFER_SIZE * 2)
+ .build()
+
+ if (audioRecord?.state != AudioRecord.STATE_INITIALIZED) {
+ Log.e(TAG, "Failed to initialize AudioRecord")
+ audioRecord?.release()
+ audioRecord = null
+ return false
+ }
+
+ // Send audio start message to Mac
+ sendAudioStart()
+
+ // Start recording
+ audioRecord?.startRecording()
+ isCapturing = true
+
+ // Start capture loop
+ captureJob = CoroutineScope(Dispatchers.IO).launch {
+ captureLoop()
+ }
+
+ Log.d(TAG, "Audio capture started: sampleRate=$SAMPLE_RATE, channels=$CHANNELS, bufferSize=$BUFFER_SIZE")
+ return true
+
+ } catch (e: Exception) {
+ Log.e(TAG, "Error starting audio capture", e)
+ stopCapture()
+ return false
+ }
+ }
+
+ /**
+ * Stop capturing audio
+ */
+ fun stopCapture() {
+ isCapturing = false
+ captureJob?.cancel()
+ captureJob = null
+
+ try {
+ audioRecord?.stop()
+ audioRecord?.release()
+ } catch (e: Exception) {
+ Log.e(TAG, "Error stopping audio record", e)
+ }
+ audioRecord = null
+
+ // Send audio stop message to Mac
+ sendAudioStop()
+
+ Log.d(TAG, "Audio capture stopped")
+ }
+
+ private suspend fun captureLoop() {
+ val buffer = ByteArray(BUFFER_SIZE)
+ var frameCount = 0L
+ var accumulatedBuffer = ByteArray(0)
+
+ while (isCapturing && captureJob?.isActive == true) {
+ try {
+ val bytesRead = audioRecord?.read(buffer, 0, buffer.size) ?: -1
+
+ if (bytesRead > 0) {
+ // Accumulate audio data
+ accumulatedBuffer += buffer.copyOf(bytesRead)
+
+ // Only send if enough time has passed (throttle to prevent flooding)
+ val now = System.currentTimeMillis()
+ if (now - lastFrameSentTime >= minFrameIntervalMs && accumulatedBuffer.isNotEmpty()) {
+ sendAudioFrame(accumulatedBuffer, frameCount++)
+ accumulatedBuffer = ByteArray(0)
+ lastFrameSentTime = now
+ }
+ } else if (bytesRead < 0) {
+ Log.e(TAG, "AudioRecord read error: $bytesRead")
+ break
+ }
+ } catch (e: Exception) {
+ Log.e(TAG, "Error in capture loop", e)
+ break
+ }
+ }
+
+ Log.d(TAG, "Capture loop ended, sent $frameCount frames")
+ }
+
+ private fun sendAudioStart() {
+ CoroutineScope(Dispatchers.IO).launch {
+ try {
+ val json = """{"type":"audioStart","data":{"sampleRate":$SAMPLE_RATE,"channels":$CHANNELS,"bitsPerSample":$BITS_PER_SAMPLE,"format":"pcm"}}"""
+ WebSocketUtil.sendMessage(json)
+ Log.d(TAG, "Sent audioStart message")
+ } catch (e: Exception) {
+ Log.e(TAG, "Error sending audioStart", e)
+ }
+ }
+ }
+
+ private fun sendAudioStop() {
+ CoroutineScope(Dispatchers.IO).launch {
+ try {
+ val json = """{"type":"audioStop","data":{}}"""
+ WebSocketUtil.sendMessage(json)
+ Log.d(TAG, "Sent audioStop message")
+ } catch (e: Exception) {
+ Log.e(TAG, "Error sending audioStop", e)
+ }
+ }
+ }
+
+ private fun sendAudioFrame(data: ByteArray, frameIndex: Long) {
+ CoroutineScope(Dispatchers.IO).launch {
+ try {
+ val base64Data = Base64.encodeToString(data, Base64.NO_WRAP)
+ val json = """{"type":"audioFrame","data":{"frame":"$base64Data","index":$frameIndex,"size":${data.size}}}"""
+ WebSocketUtil.sendMessage(json)
+ } catch (e: Exception) {
+ Log.e(TAG, "Error sending audio frame", e)
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/com/sameerasw/airsync/service/BackgroundSyncService.kt b/app/src/main/java/com/sameerasw/airsync/service/BackgroundSyncService.kt
new file mode 100644
index 0000000..687525b
--- /dev/null
+++ b/app/src/main/java/com/sameerasw/airsync/service/BackgroundSyncService.kt
@@ -0,0 +1,197 @@
+package com.sameerasw.airsync.service
+
+import android.app.Notification
+import android.app.NotificationChannel
+import android.app.NotificationManager
+import android.app.PendingIntent
+import android.app.Service
+import android.content.Context
+import android.content.Intent
+import android.os.IBinder
+import android.util.Log
+import androidx.core.app.NotificationCompat
+import com.sameerasw.airsync.MainActivity
+import com.sameerasw.airsync.R
+import com.sameerasw.airsync.data.local.DataStoreManager
+import com.sameerasw.airsync.utils.SyncManager
+import com.sameerasw.airsync.utils.WebSocketUtil
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.isActive
+import kotlinx.coroutines.launch
+
+/**
+ * Background service that keeps the app connected and syncing data
+ * - Maintains WebSocket connection
+ * - Syncs notifications in real-time
+ * - Syncs health data periodically
+ * - Syncs calls and messages
+ */
+class BackgroundSyncService : Service() {
+
+ companion object {
+ private const val TAG = "BackgroundSyncService"
+ private const val NOTIFICATION_ID = 1001
+ private const val CHANNEL_ID = "airsync_background_sync"
+
+ private var isRunning = false
+
+ fun start(context: Context) {
+ if (isRunning) {
+ Log.d(TAG, "Service already running")
+ return
+ }
+
+ val intent = Intent(context, BackgroundSyncService::class.java)
+ if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.O) {
+ context.startForegroundService(intent)
+ } else {
+ context.startService(intent)
+ }
+ }
+
+ fun stop(context: Context) {
+ val intent = Intent(context, BackgroundSyncService::class.java)
+ context.stopService(intent)
+ }
+
+ fun isRunning(): Boolean = isRunning
+ }
+
+ private val serviceJob = Job()
+ private val serviceScope = CoroutineScope(Dispatchers.IO + serviceJob)
+ private lateinit var dataStoreManager: DataStoreManager
+
+ private var syncJob: Job? = null
+ private var reconnectJob: Job? = null
+
+ override fun onCreate() {
+ super.onCreate()
+ Log.d(TAG, "Background sync service created")
+ dataStoreManager = DataStoreManager(this)
+ isRunning = true
+
+ createNotificationChannel()
+ startForeground(NOTIFICATION_ID, createNotification("Starting..."))
+ }
+
+ override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
+ Log.d(TAG, "Background sync service started")
+
+ // Start syncing
+ startSyncing()
+
+ // Keep service alive
+ return START_STICKY
+ }
+
+ override fun onBind(intent: Intent?): IBinder? = null
+
+ override fun onDestroy() {
+ super.onDestroy()
+ Log.d(TAG, "Background sync service destroyed")
+
+ isRunning = false
+ syncJob?.cancel()
+ reconnectJob?.cancel()
+ serviceJob.cancel()
+
+ // Stop periodic sync
+ SyncManager.stopPeriodicSync()
+ }
+
+ private fun startSyncing() {
+ syncJob?.cancel()
+
+ syncJob = serviceScope.launch {
+ while (isActive) {
+ try {
+ if (WebSocketUtil.isConnected()) {
+ updateNotification("Connected - Syncing data")
+
+ // Start periodic sync if not already running
+ SyncManager.startPeriodicSync(this@BackgroundSyncService)
+
+ // Wait and check connection
+ delay(30_000) // Check every 30 seconds
+ } else {
+ updateNotification("Disconnected - Attempting to reconnect")
+
+ // Try to reconnect
+ attemptReconnect()
+
+ delay(10_000) // Wait 10 seconds before next attempt
+ }
+ } catch (e: Exception) {
+ Log.e(TAG, "Error in sync loop: ${e.message}", e)
+ delay(10_000)
+ }
+ }
+ }
+ }
+
+ private suspend fun attemptReconnect() {
+ try {
+ val ipAddress = dataStoreManager.getIpAddress().first()
+ val port = dataStoreManager.getPort().first().toIntOrNull() ?: 6996
+ val symmetricKey = dataStoreManager.getLastConnectedDevice().first()?.symmetricKey
+ val autoReconnect = dataStoreManager.getAutoReconnectEnabled().first()
+
+ if (ipAddress.isNotEmpty() && autoReconnect) {
+ Log.d(TAG, "Attempting to reconnect to $ipAddress:$port")
+
+ WebSocketUtil.connect(
+ context = this@BackgroundSyncService,
+ ipAddress = ipAddress,
+ port = port,
+ symmetricKey = symmetricKey,
+ manualAttempt = false
+ )
+ }
+ } catch (e: Exception) {
+ Log.e(TAG, "Error attempting reconnect: ${e.message}", e)
+ }
+ }
+
+ private fun createNotificationChannel() {
+ val channel = NotificationChannel(
+ CHANNEL_ID,
+ "Background Sync",
+ NotificationManager.IMPORTANCE_LOW
+ ).apply {
+ description = "Keeps AirSync connected and syncing data"
+ setShowBadge(false)
+ }
+
+ val notificationManager = getSystemService(NotificationManager::class.java)
+ notificationManager.createNotificationChannel(channel)
+ }
+
+ private fun createNotification(status: String): Notification {
+ val intent = Intent(this, MainActivity::class.java)
+ val pendingIntent = PendingIntent.getActivity(
+ this,
+ 0,
+ intent,
+ PendingIntent.FLAG_IMMUTABLE
+ )
+
+ return NotificationCompat.Builder(this, CHANNEL_ID)
+ .setContentTitle("AirSync")
+ .setContentText(status)
+ .setSmallIcon(R.drawable.ic_launcher_foreground)
+ .setContentIntent(pendingIntent)
+ .setOngoing(true)
+ .setPriority(NotificationCompat.PRIORITY_LOW)
+ .setCategory(NotificationCompat.CATEGORY_SERVICE)
+ .build()
+ }
+
+ private fun updateNotification(status: String) {
+ val notificationManager = getSystemService(NotificationManager::class.java)
+ notificationManager.notify(NOTIFICATION_ID, createNotification(status))
+ }
+}
diff --git a/app/src/main/java/com/sameerasw/airsync/service/CallStateCallback.kt b/app/src/main/java/com/sameerasw/airsync/service/CallStateCallback.kt
index 5548332..84a814c 100644
--- a/app/src/main/java/com/sameerasw/airsync/service/CallStateCallback.kt
+++ b/app/src/main/java/com/sameerasw/airsync/service/CallStateCallback.kt
@@ -120,6 +120,7 @@ class CallStateListener(private val context: Context) {
/**
* Send a call event to the Mac app via WebSocket with all details
+ * Only sends if WebSocket is connected to Mac
*/
private suspend fun sendCallEvent(
context: Context,
@@ -127,6 +128,12 @@ class CallStateListener(private val context: Context) {
direction: String,
phoneNumber: String?
) {
+ // Only send call events when connected to Mac
+ if (!WebSocketUtil.isConnected()) {
+ Log.d(TAG, "Skipping call event - not connected to Mac (state: $state)")
+ return
+ }
+
try {
val displayNumber = phoneNumber?.takeIf { it.isNotBlank() } ?: "Unknown"
diff --git a/app/src/main/java/com/sameerasw/airsync/service/InputAccessibilityService.kt b/app/src/main/java/com/sameerasw/airsync/service/InputAccessibilityService.kt
new file mode 100644
index 0000000..2cb6bc0
--- /dev/null
+++ b/app/src/main/java/com/sameerasw/airsync/service/InputAccessibilityService.kt
@@ -0,0 +1,475 @@
+package com.sameerasw.airsync.service
+
+import android.accessibilityservice.AccessibilityService
+import android.accessibilityservice.GestureDescription
+import android.graphics.Path
+import android.util.Log
+import android.view.accessibility.AccessibilityEvent
+import android.content.Context
+
+class InputAccessibilityService : AccessibilityService() {
+
+ companion object {
+ private const val TAG = "InputAccessibilityService"
+ var instance: InputAccessibilityService? = null
+
+ private const val TAP_DURATION = 1L
+ private const val LONG_PRESS_DURATION = 500L
+ private const val SWIPE_DURATION = 300L
+ }
+
+ override fun onAccessibilityEvent(event: AccessibilityEvent?) {
+ // Not needed for this implementation
+ }
+
+ override fun onInterrupt() {
+ // Not needed for this implementation
+ }
+
+ override fun onServiceConnected() {
+ super.onServiceConnected()
+ Log.d(TAG, "InputAccessibilityService connected")
+ instance = this
+ }
+
+ override fun onDestroy() {
+ super.onDestroy()
+ Log.d(TAG, "InputAccessibilityService destroyed")
+ instance = null
+ }
+
+ fun injectTouchEvent(x: Float, y: Float) {
+ injectTap(x, y)
+ }
+
+ fun injectTap(x: Float, y: Float) {
+ val path = Path().apply {
+ moveTo(x, y)
+ }
+ val gesture = GestureDescription.Builder()
+ .addStroke(GestureDescription.StrokeDescription(path, 0, TAP_DURATION))
+ .build()
+
+ dispatchGesture(gesture, object : GestureResultCallback() {
+ override fun onCompleted(gestureDescription: GestureDescription?) {
+ super.onCompleted(gestureDescription)
+ Log.d(TAG, "Tap gesture completed at ($x, $y)")
+ }
+
+ override fun onCancelled(gestureDescription: GestureDescription?) {
+ super.onCancelled(gestureDescription)
+ Log.e(TAG, "Tap gesture cancelled")
+ }
+ }, null)
+ }
+
+ fun injectLongPress(x: Float, y: Float) {
+ val path = Path().apply {
+ moveTo(x, y)
+ }
+ val gesture = GestureDescription.Builder()
+ .addStroke(GestureDescription.StrokeDescription(path, 0, LONG_PRESS_DURATION))
+ .build()
+
+ dispatchGesture(gesture, object : GestureResultCallback() {
+ override fun onCompleted(gestureDescription: GestureDescription?) {
+ super.onCompleted(gestureDescription)
+ Log.d(TAG, "Long press gesture completed at ($x, $y)")
+ }
+
+ override fun onCancelled(gestureDescription: GestureDescription?) {
+ super.onCancelled(gestureDescription)
+ Log.e(TAG, "Long press gesture cancelled")
+ }
+ }, null)
+ }
+
+ fun injectSwipe(startX: Float, startY: Float, endX: Float, endY: Float, duration: Long = SWIPE_DURATION) {
+ val path = Path().apply {
+ moveTo(startX, startY)
+ lineTo(endX, endY)
+ }
+ val gesture = GestureDescription.Builder()
+ .addStroke(GestureDescription.StrokeDescription(path, 0, duration))
+ .build()
+
+ dispatchGesture(gesture, object : GestureResultCallback() {
+ override fun onCompleted(gestureDescription: GestureDescription?) {
+ super.onCompleted(gestureDescription)
+ Log.d(TAG, "Swipe gesture completed from ($startX, $startY) to ($endX, $endY)")
+ }
+
+ override fun onCancelled(gestureDescription: GestureDescription?) {
+ super.onCancelled(gestureDescription)
+ Log.e(TAG, "Swipe gesture cancelled")
+ }
+ }, null)
+ }
+
+ fun injectScroll(x: Float, y: Float, deltaX: Float, deltaY: Float) {
+ // Convert scroll delta to swipe gesture
+ val endX = x + deltaX
+ val endY = y + deltaY
+ injectSwipe(x, y, endX, endY, 100L)
+ }
+
+ fun performBack(): Boolean {
+ return try {
+ val result = performGlobalAction(GLOBAL_ACTION_BACK)
+ Log.d(TAG, "Back action: ${if (result) "success" else "failed"}")
+ result
+ } catch (e: Exception) {
+ Log.e(TAG, "Error performing back action", e)
+ false
+ }
+ }
+
+ fun performHome(): Boolean {
+ return try {
+ val result = performGlobalAction(GLOBAL_ACTION_HOME)
+ Log.d(TAG, "Home action: ${if (result) "success" else "failed"}")
+ result
+ } catch (e: Exception) {
+ Log.e(TAG, "Error performing home action", e)
+ false
+ }
+ }
+
+ fun performRecents(): Boolean {
+ return try {
+ val result = performGlobalAction(GLOBAL_ACTION_RECENTS)
+ Log.d(TAG, "Recents action: ${if (result) "success" else "failed"}")
+ result
+ } catch (e: Exception) {
+ Log.e(TAG, "Error performing recents action", e)
+ false
+ }
+ }
+
+ fun performNotifications(): Boolean {
+ return try {
+ val result = performGlobalAction(GLOBAL_ACTION_NOTIFICATIONS)
+ Log.d(TAG, "Notifications action: ${if (result) "success" else "failed"}")
+ result
+ } catch (e: Exception) {
+ Log.e(TAG, "Error performing notifications action", e)
+ false
+ }
+ }
+
+ fun performQuickSettings(): Boolean {
+ return try {
+ val result = performGlobalAction(GLOBAL_ACTION_QUICK_SETTINGS)
+ Log.d(TAG, "Quick settings action: ${if (result) "success" else "failed"}")
+ result
+ } catch (e: Exception) {
+ Log.e(TAG, "Error performing quick settings action", e)
+ false
+ }
+ }
+
+ fun performPowerDialog(): Boolean {
+ return try {
+ val result = performGlobalAction(GLOBAL_ACTION_POWER_DIALOG)
+ Log.d(TAG, "Power dialog action: ${if (result) "success" else "failed"}")
+ result
+ } catch (e: Exception) {
+ Log.e(TAG, "Error performing power dialog action", e)
+ false
+ }
+ }
+
+
+ /**
+ * Inject text input using accessibility service
+ * Falls back to shell command if no focused input field
+ */
+ fun injectText(text: String): Boolean {
+ return try {
+ val rootNode = this.rootInActiveWindow
+ if (rootNode == null) {
+ Log.w(TAG, "Root node is null, trying shell input")
+ return injectTextViaShell(text)
+ }
+
+ // Try to find focused input field
+ var focusedNode = rootNode.findFocus(android.view.accessibility.AccessibilityNodeInfo.FOCUS_INPUT)
+
+ // If no input focus, try to find any editable node
+ if (focusedNode == null) {
+ focusedNode = findEditableNode(rootNode)
+ }
+
+ if (focusedNode == null) {
+ Log.w(TAG, "No focused or editable input field found, trying shell input")
+ rootNode.recycle()
+ return injectTextViaShell(text)
+ }
+
+ // Check if node is editable
+ if (!focusedNode.isEditable) {
+ Log.w(TAG, "Focused node is not editable, trying shell input")
+ focusedNode.recycle()
+ rootNode.recycle()
+ return injectTextViaShell(text)
+ }
+
+ // Get current text and append new text
+ val currentText = focusedNode.text?.toString() ?: ""
+ val newText = currentText + text
+
+ val arguments = android.os.Bundle()
+ arguments.putCharSequence(android.view.accessibility.AccessibilityNodeInfo.ACTION_ARGUMENT_SET_TEXT_CHARSEQUENCE, newText)
+ val result = focusedNode.performAction(android.view.accessibility.AccessibilityNodeInfo.ACTION_SET_TEXT, arguments)
+
+ focusedNode.recycle()
+ rootNode.recycle()
+
+ if (!result) {
+ Log.w(TAG, "Accessibility text injection failed, trying PASTE")
+ // Try to paste the text instead
+ val clipboard = getSystemService(Context.CLIPBOARD_SERVICE) as android.content.ClipboardManager
+ val clip = android.content.ClipData.newPlainText("AirSync Input", text)
+ clipboard.setPrimaryClip(clip)
+
+ val pasteResult = focusedNode.performAction(android.view.accessibility.AccessibilityNodeInfo.ACTION_PASTE)
+ if (pasteResult) {
+ Log.d(TAG, "Text injection via PASTE succeeded")
+ return true
+ }
+
+ Log.w(TAG, "Paste failed, trying shell input")
+ return injectTextViaShell(text)
+ }
+
+ Log.d(TAG, "Text injection succeeded: $text")
+ return true
+ } catch (e: Exception) {
+ Log.e(TAG, "Error injecting text via accessibility, trying shell", e)
+ return injectTextViaShell(text)
+ }
+ }
+
+ /**
+ * Find an editable node in the view hierarchy
+ */
+ private fun findEditableNode(node: android.view.accessibility.AccessibilityNodeInfo): android.view.accessibility.AccessibilityNodeInfo? {
+ if (node.isEditable && node.isFocused) {
+ return node
+ }
+
+ for (i in 0 until node.childCount) {
+ val child = node.getChild(i) ?: continue
+ if (child.isEditable && child.isFocused) {
+ return child
+ }
+ val found = findEditableNode(child)
+ if (found != null) {
+ child.recycle()
+ return found
+ }
+ child.recycle()
+ }
+ return null
+ }
+
+ /**
+ * Inject text via shell command (fallback)
+ * Note: This requires either root access or ADB connection
+ */
+ private fun injectTextViaShell(text: String): Boolean {
+ return try {
+ // For shell input, we need to escape special characters properly
+ // The 'input text' command has specific escaping requirements
+ val escapedText = text
+ .replace("\\", "\\\\\\\\") // Backslash needs quadruple escape
+ .replace("\"", "\\\\\\\"") // Quote needs triple escape
+ .replace("'", "'\\''") // Single quote: end quote, escaped quote, start quote
+ .replace(" ", "%s") // Space as %s (input text special)
+ .replace("&", "\\&")
+ .replace("|", "\\|")
+ .replace(";", "\\;")
+ .replace("(", "\\(")
+ .replace(")", "\\)")
+ .replace("<", "\\<")
+ .replace(">", "\\>")
+ .replace("\n", "") // Remove newlines (not supported)
+ .replace("\t", "") // Remove tabs (not supported)
+
+ // Use ProcessBuilder for better control
+ val processBuilder = ProcessBuilder("sh", "-c", "input text \"$escapedText\"")
+ processBuilder.redirectErrorStream(true)
+ val process = processBuilder.start()
+
+ // Read output for debugging
+ val output = process.inputStream.bufferedReader().readText()
+ val exitCode = process.waitFor()
+
+ val result = exitCode == 0
+ if (!result) {
+ Log.e(TAG, "Shell text injection failed: $text (exit=$exitCode, output=$output)")
+ } else {
+ Log.d(TAG, "Shell text injection succeeded: $text")
+ }
+ result
+ } catch (e: Exception) {
+ Log.e(TAG, "Error injecting text via shell: ${e.message}", e)
+ false
+ }
+ }
+
+ /**
+ * Inject key event using accessibility service
+ * Falls back to shell command for unsupported keys
+ */
+ fun injectKeyEvent(keyCode: Int): Boolean {
+ return try {
+ when (keyCode) {
+ 67 -> { // KEYCODE_DEL - Backspace
+ val rootNode = this.rootInActiveWindow
+ val focusedNode = rootNode?.findFocus(android.view.accessibility.AccessibilityNodeInfo.FOCUS_INPUT)
+ val result = if (focusedNode != null) {
+ val currentText = focusedNode.text?.toString() ?: ""
+ if (currentText.isNotEmpty()) {
+ val newText = currentText.dropLast(1)
+ val arguments = android.os.Bundle()
+ arguments.putCharSequence(android.view.accessibility.AccessibilityNodeInfo.ACTION_ARGUMENT_SET_TEXT_CHARSEQUENCE, newText)
+ focusedNode.performAction(android.view.accessibility.AccessibilityNodeInfo.ACTION_SET_TEXT, arguments)
+ } else {
+ injectKeyViaShell(keyCode)
+ }
+ } else {
+ injectKeyViaShell(keyCode)
+ }
+ focusedNode?.recycle()
+ rootNode?.recycle()
+ Log.d(TAG, "Backspace key ${if (result) "succeeded" else "failed"}")
+ result
+ }
+ 66 -> { // KEYCODE_ENTER
+ if (!injectText("\n")) {
+ injectKeyViaShell(keyCode)
+ } else true
+ }
+ 62 -> { // KEYCODE_SPACE
+ if (!injectText(" ")) {
+ injectKeyViaShell(keyCode)
+ } else true
+ }
+ 61 -> { // KEYCODE_TAB
+ if (!injectText("\t")) {
+ injectKeyViaShell(keyCode)
+ } else true
+ }
+ // Arrow keys - use accessibility cursor movement actions
+ 21 -> { // KEYCODE_DPAD_LEFT
+ injectCursorMove(android.view.accessibility.AccessibilityNodeInfo.MOVEMENT_GRANULARITY_CHARACTER, false)
+ }
+ 22 -> { // KEYCODE_DPAD_RIGHT
+ injectCursorMove(android.view.accessibility.AccessibilityNodeInfo.MOVEMENT_GRANULARITY_CHARACTER, true)
+ }
+ 19 -> { // KEYCODE_DPAD_UP
+ injectCursorMove(android.view.accessibility.AccessibilityNodeInfo.MOVEMENT_GRANULARITY_LINE, false)
+ }
+ 20 -> { // KEYCODE_DPAD_DOWN
+ injectCursorMove(android.view.accessibility.AccessibilityNodeInfo.MOVEMENT_GRANULARITY_LINE, true)
+ }
+ // Page Up/Down
+ 92 -> { // KEYCODE_PAGE_UP
+ injectCursorMove(android.view.accessibility.AccessibilityNodeInfo.MOVEMENT_GRANULARITY_PAGE, false)
+ }
+ 93 -> { // KEYCODE_PAGE_DOWN
+ injectCursorMove(android.view.accessibility.AccessibilityNodeInfo.MOVEMENT_GRANULARITY_PAGE, true)
+ }
+ // Home/End
+ 122 -> { // KEYCODE_MOVE_HOME - move to start of text
+ val rootNode = this.rootInActiveWindow
+ val focusedNode = rootNode?.findFocus(android.view.accessibility.AccessibilityNodeInfo.FOCUS_INPUT)
+ val result = focusedNode?.performAction(android.view.accessibility.AccessibilityNodeInfo.ACTION_SET_SELECTION, android.os.Bundle().apply {
+ putInt(android.view.accessibility.AccessibilityNodeInfo.ACTION_ARGUMENT_SELECTION_START_INT, 0)
+ putInt(android.view.accessibility.AccessibilityNodeInfo.ACTION_ARGUMENT_SELECTION_END_INT, 0)
+ }) ?: false
+ focusedNode?.recycle()
+ rootNode?.recycle()
+ result
+ }
+ 123 -> { // KEYCODE_MOVE_END - move to end of text
+ val rootNode = this.rootInActiveWindow
+ val focusedNode = rootNode?.findFocus(android.view.accessibility.AccessibilityNodeInfo.FOCUS_INPUT)
+ val textLength = focusedNode?.text?.length ?: 0
+ val result = focusedNode?.performAction(android.view.accessibility.AccessibilityNodeInfo.ACTION_SET_SELECTION, android.os.Bundle().apply {
+ putInt(android.view.accessibility.AccessibilityNodeInfo.ACTION_ARGUMENT_SELECTION_START_INT, textLength)
+ putInt(android.view.accessibility.AccessibilityNodeInfo.ACTION_ARGUMENT_SELECTION_END_INT, textLength)
+ }) ?: false
+ focusedNode?.recycle()
+ rootNode?.recycle()
+ result
+ }
+ else -> {
+ Log.d(TAG, "Using shell for key code: $keyCode")
+ injectKeyViaShell(keyCode)
+ }
+ }
+ } catch (e: Exception) {
+ Log.e(TAG, "Error injecting key event", e)
+ injectKeyViaShell(keyCode)
+ }
+ }
+
+ /**
+ * Inject cursor movement using accessibility actions
+ */
+ private fun injectCursorMove(granularity: Int, forward: Boolean): Boolean {
+ return try {
+ val rootNode = this.rootInActiveWindow
+ val focusedNode = rootNode?.findFocus(android.view.accessibility.AccessibilityNodeInfo.FOCUS_INPUT)
+
+ val result = if (focusedNode != null) {
+ val action = if (forward) {
+ android.view.accessibility.AccessibilityNodeInfo.ACTION_NEXT_AT_MOVEMENT_GRANULARITY
+ } else {
+ android.view.accessibility.AccessibilityNodeInfo.ACTION_PREVIOUS_AT_MOVEMENT_GRANULARITY
+ }
+ val arguments = android.os.Bundle().apply {
+ putInt(android.view.accessibility.AccessibilityNodeInfo.ACTION_ARGUMENT_MOVEMENT_GRANULARITY_INT, granularity)
+ }
+ focusedNode.performAction(action, arguments)
+ } else {
+ Log.w(TAG, "No focused input for cursor move, trying shell fallback")
+ // Fallback to shell for non-text-field contexts (e.g., list navigation)
+ when (granularity) {
+ android.view.accessibility.AccessibilityNodeInfo.MOVEMENT_GRANULARITY_CHARACTER ->
+ injectKeyViaShell(if (forward) 22 else 21) // DPAD RIGHT/LEFT
+ android.view.accessibility.AccessibilityNodeInfo.MOVEMENT_GRANULARITY_LINE ->
+ injectKeyViaShell(if (forward) 20 else 19) // DPAD DOWN/UP
+ else -> false
+ }
+ }
+
+ focusedNode?.recycle()
+ rootNode?.recycle()
+
+ Log.d(TAG, "Cursor move (granularity=$granularity, forward=$forward): ${if (result) "succeeded" else "failed"}")
+ result
+ } catch (e: Exception) {
+ Log.e(TAG, "Error moving cursor", e)
+ false
+ }
+ }
+
+ /**
+ * Inject key event via shell command (fallback)
+ */
+ private fun injectKeyViaShell(keyCode: Int): Boolean {
+ return try {
+ val process = Runtime.getRuntime().exec(arrayOf("sh", "-c", "input keyevent $keyCode"))
+ val exitCode = process.waitFor()
+ val result = exitCode == 0
+ Log.d(TAG, "Shell key injection ${if (result) "succeeded" else "failed"}: keyCode=$keyCode")
+ result
+ } catch (e: Exception) {
+ Log.e(TAG, "Error injecting key via shell", e)
+ false
+ }
+ }
+}
diff --git a/app/src/main/java/com/sameerasw/airsync/service/LiveNotificationService.kt b/app/src/main/java/com/sameerasw/airsync/service/LiveNotificationService.kt
new file mode 100644
index 0000000..56834ba
--- /dev/null
+++ b/app/src/main/java/com/sameerasw/airsync/service/LiveNotificationService.kt
@@ -0,0 +1,522 @@
+package com.sameerasw.airsync.service
+
+import android.app.Notification
+import android.app.NotificationChannel
+import android.app.NotificationManager
+import android.app.PendingIntent
+import android.app.Service
+import android.content.Context
+import android.content.Intent
+import android.graphics.Bitmap
+import android.graphics.BitmapFactory
+import android.os.Build
+import android.os.IBinder
+import android.util.Base64
+import android.util.Log
+import androidx.core.app.NotificationCompat
+import androidx.core.app.Person
+import com.sameerasw.airsync.MainActivity
+import com.sameerasw.airsync.R
+import com.sameerasw.airsync.models.CallState
+import com.sameerasw.airsync.models.OngoingCall
+import com.sameerasw.airsync.utils.WebSocketUtil
+import java.util.concurrent.ConcurrentHashMap
+
+/**
+ * Service for managing live notifications (calls, timers, etc.)
+ * Supports Android's live notification updates API
+ */
+class LiveNotificationService : Service() {
+
+ companion object {
+ private const val TAG = "LiveNotificationService"
+
+ // Notification channels
+ private const val CHANNEL_CALL = "live_call_channel"
+ private const val CHANNEL_TIMER = "live_timer_channel"
+ private const val CHANNEL_STOPWATCH = "live_stopwatch_channel"
+
+ // Actions
+ const val ACTION_SHOW_CALL = "SHOW_CALL"
+ const val ACTION_UPDATE_CALL = "UPDATE_CALL"
+ const val ACTION_DISMISS_CALL = "DISMISS_CALL"
+ const val ACTION_ANSWER_CALL = "ANSWER_CALL"
+ const val ACTION_REJECT_CALL = "REJECT_CALL"
+ const val ACTION_END_CALL = "END_CALL"
+
+ // Extras
+ const val EXTRA_CALL_ID = "call_id"
+ const val EXTRA_NUMBER = "number"
+ const val EXTRA_CONTACT_NAME = "contact_name"
+ const val EXTRA_STATE = "state"
+ const val EXTRA_START_TIME = "start_time"
+ const val EXTRA_IS_INCOMING = "is_incoming"
+ const val EXTRA_ALBUM_ART = "album_art"
+
+ private var instance: LiveNotificationService? = null
+
+ // Active notifications
+ private val activeNotifications = ConcurrentHashMap()
+ private var nextNotificationId = 2000
+
+ fun showCallNotification(
+ context: Context,
+ call: OngoingCall,
+ albumArt: Bitmap? = null
+ ) {
+ val intent = Intent(context, LiveNotificationService::class.java).apply {
+ action = ACTION_SHOW_CALL
+ putExtra(EXTRA_CALL_ID, call.id)
+ putExtra(EXTRA_NUMBER, call.number)
+ putExtra(EXTRA_CONTACT_NAME, call.contactName)
+ putExtra(EXTRA_STATE, call.state.name)
+ putExtra(EXTRA_START_TIME, call.startTime)
+ putExtra(EXTRA_IS_INCOMING, call.isIncoming)
+ }
+ context.startService(intent)
+
+ // Store album art separately if provided
+ instance?.storeAlbumArt(call.id, albumArt)
+ }
+
+ fun updateCallNotification(
+ context: Context,
+ call: OngoingCall,
+ albumArt: Bitmap? = null
+ ) {
+ val intent = Intent(context, LiveNotificationService::class.java).apply {
+ action = ACTION_UPDATE_CALL
+ putExtra(EXTRA_CALL_ID, call.id)
+ putExtra(EXTRA_NUMBER, call.number)
+ putExtra(EXTRA_CONTACT_NAME, call.contactName)
+ putExtra(EXTRA_STATE, call.state.name)
+ putExtra(EXTRA_START_TIME, call.startTime)
+ putExtra(EXTRA_IS_INCOMING, call.isIncoming)
+ }
+ context.startService(intent)
+
+ // Update album art if provided
+ albumArt?.let {
+ instance?.storeAlbumArt(call.id, it)
+ }
+ }
+
+ fun dismissCallNotification(context: Context, callId: String) {
+ val intent = Intent(context, LiveNotificationService::class.java).apply {
+ action = ACTION_DISMISS_CALL
+ putExtra(EXTRA_CALL_ID, callId)
+ }
+ context.startService(intent)
+ }
+ }
+
+ private val albumArtCache = ConcurrentHashMap()
+
+ override fun onCreate() {
+ super.onCreate()
+ instance = this
+ createNotificationChannels()
+ Log.d(TAG, "LiveNotificationService created")
+ }
+
+ override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
+ when (intent?.action) {
+ ACTION_SHOW_CALL -> handleShowCall(intent)
+ ACTION_UPDATE_CALL -> handleUpdateCall(intent)
+ ACTION_DISMISS_CALL -> handleDismissCall(intent)
+ ACTION_ANSWER_CALL -> handleAnswerCall(intent)
+ ACTION_REJECT_CALL -> handleRejectCall(intent)
+ ACTION_END_CALL -> handleEndCall(intent)
+ }
+
+ // Stop service if no active notifications
+ if (activeNotifications.isEmpty()) {
+ stopSelf()
+ }
+
+ return START_NOT_STICKY
+ }
+
+ override fun onBind(intent: Intent?): IBinder? = null
+
+ private fun createNotificationChannels() {
+ val notificationManager = getSystemService(NotificationManager::class.java)
+
+ // Call notification channel
+ val callChannel = NotificationChannel(
+ CHANNEL_CALL,
+ "Live Calls",
+ NotificationManager.IMPORTANCE_HIGH
+ ).apply {
+ description = "Ongoing call notifications"
+ setSound(null, null)
+ enableVibration(true)
+ enableLights(true)
+ }
+
+ // Timer channel
+ val timerChannel = NotificationChannel(
+ CHANNEL_TIMER,
+ "Timers",
+ NotificationManager.IMPORTANCE_DEFAULT
+ ).apply {
+ description = "Active timer notifications"
+ setSound(null, null)
+ }
+
+ // Stopwatch channel
+ val stopwatchChannel = NotificationChannel(
+ CHANNEL_STOPWATCH,
+ "Stopwatch",
+ NotificationManager.IMPORTANCE_LOW
+ ).apply {
+ description = "Active stopwatch notifications"
+ setSound(null, null)
+ }
+
+ notificationManager.createNotificationChannels(
+ listOf(callChannel, timerChannel, stopwatchChannel)
+ )
+ }
+
+ private fun handleShowCall(intent: Intent) {
+ val callId = intent.getStringExtra(EXTRA_CALL_ID) ?: return
+ val number = intent.getStringExtra(EXTRA_NUMBER) ?: ""
+ val contactName = intent.getStringExtra(EXTRA_CONTACT_NAME)
+ val stateString = intent.getStringExtra(EXTRA_STATE) ?: return
+ val startTime = intent.getLongExtra(EXTRA_START_TIME, System.currentTimeMillis())
+ val isIncoming = intent.getBooleanExtra(EXTRA_IS_INCOMING, true)
+
+ val state = try {
+ CallState.valueOf(stateString)
+ } catch (e: Exception) {
+ CallState.RINGING
+ }
+
+ val call = OngoingCall(
+ id = callId,
+ number = number,
+ contactName = contactName,
+ state = state,
+ startTime = startTime,
+ isIncoming = isIncoming
+ )
+
+ val notificationId = activeNotifications.getOrPut(callId) {
+ nextNotificationId++
+ }
+
+ val notification = createCallNotification(call)
+
+ // CallStyle notifications require foreground service
+ try {
+ startForeground(notificationId, notification)
+ Log.d(TAG, "Started foreground service with call notification for $callId")
+ } catch (e: Exception) {
+ Log.e(TAG, "Failed to start foreground service, falling back to regular notification", e)
+ // Fallback: create a regular notification without CallStyle
+ val fallbackNotification = createFallbackCallNotification(call)
+ val notificationManager = getSystemService(NotificationManager::class.java)
+ notificationManager.notify(notificationId, fallbackNotification)
+ }
+ }
+
+ private fun handleUpdateCall(intent: Intent) {
+ val callId = intent.getStringExtra(EXTRA_CALL_ID) ?: return
+ val notificationId = activeNotifications[callId] ?: return
+
+ val number = intent.getStringExtra(EXTRA_NUMBER) ?: ""
+ val contactName = intent.getStringExtra(EXTRA_CONTACT_NAME)
+ val stateString = intent.getStringExtra(EXTRA_STATE) ?: return
+ val startTime = intent.getLongExtra(EXTRA_START_TIME, System.currentTimeMillis())
+ val isIncoming = intent.getBooleanExtra(EXTRA_IS_INCOMING, true)
+
+ val state = try {
+ CallState.valueOf(stateString)
+ } catch (e: Exception) {
+ CallState.ACTIVE
+ }
+
+ val call = OngoingCall(
+ id = callId,
+ number = number,
+ contactName = contactName,
+ state = state,
+ startTime = startTime,
+ isIncoming = isIncoming
+ )
+
+ val notification = createCallNotification(call)
+ val notificationManager = getSystemService(NotificationManager::class.java)
+ notificationManager.notify(notificationId, notification)
+
+ Log.d(TAG, "Updated call notification for $callId")
+ }
+
+ private fun handleDismissCall(intent: Intent) {
+ val callId = intent.getStringExtra(EXTRA_CALL_ID) ?: return
+ val notificationId = activeNotifications.remove(callId) ?: return
+
+ val notificationManager = getSystemService(NotificationManager::class.java)
+ notificationManager.cancel(notificationId)
+
+ // Remove cached album art
+ albumArtCache.remove(callId)
+
+ // Stop foreground service if no more active calls
+ if (activeNotifications.isEmpty()) {
+ try {
+ stopForeground(STOP_FOREGROUND_REMOVE)
+ Log.d(TAG, "Stopped foreground service - no more active calls")
+ } catch (e: Exception) {
+ Log.w(TAG, "Failed to stop foreground service", e)
+ }
+ }
+
+ Log.d(TAG, "Dismissed call notification for $callId")
+ }
+
+ private fun handleAnswerCall(intent: Intent) {
+ val callId = intent.getStringExtra(EXTRA_CALL_ID) ?: return
+
+ // Send answer command to Mac
+ val json = """{"type":"callAction","data":{"action":"answer","callId":"$callId"}}"""
+ WebSocketUtil.sendMessage(json)
+
+ Log.d(TAG, "Answer call action for $callId")
+ }
+
+ private fun handleRejectCall(intent: Intent) {
+ val callId = intent.getStringExtra(EXTRA_CALL_ID) ?: return
+
+ // Send reject command to Mac
+ val json = """{"type":"callAction","data":{"action":"reject","callId":"$callId"}}"""
+ WebSocketUtil.sendMessage(json)
+
+ // Dismiss notification
+ handleDismissCall(intent)
+
+ Log.d(TAG, "Reject call action for $callId")
+ }
+
+ private fun handleEndCall(intent: Intent) {
+ val callId = intent.getStringExtra(EXTRA_CALL_ID) ?: return
+
+ // Send end command to Mac
+ val json = """{"type":"callAction","data":{"action":"end","callId":"$callId"}}"""
+ WebSocketUtil.sendMessage(json)
+
+ // Dismiss notification
+ handleDismissCall(intent)
+
+ Log.d(TAG, "End call action for $callId")
+ }
+
+ private fun createCallNotification(call: OngoingCall): Notification {
+ val displayName = call.contactName ?: call.number
+
+ val contentIntent = Intent(this, MainActivity::class.java).apply {
+ flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP
+ }
+ val contentPendingIntent = PendingIntent.getActivity(
+ this, 0, contentIntent, PendingIntent.FLAG_IMMUTABLE
+ )
+
+ val builder = NotificationCompat.Builder(this, CHANNEL_CALL)
+ .setSmallIcon(R.drawable.ic_launcher_foreground)
+ .setContentTitle(displayName)
+ .setContentIntent(contentPendingIntent)
+ .setOngoing(call.state != CallState.DISCONNECTED)
+ .setAutoCancel(call.state == CallState.DISCONNECTED)
+ .setCategory(NotificationCompat.CATEGORY_CALL)
+ .setPriority(NotificationCompat.PRIORITY_HIGH)
+
+ // Add album art if available
+ albumArtCache[call.id]?.let { bitmap ->
+ builder.setLargeIcon(bitmap)
+ }
+
+ // Set content text based on state
+ val contentText = when (call.state) {
+ CallState.RINGING -> if (call.isIncoming) "Incoming call" else "Outgoing call"
+ CallState.ACTIVE -> {
+ val duration = (System.currentTimeMillis() - call.startTime) / 1000
+ val minutes = duration / 60
+ val seconds = duration % 60
+ String.format("Call in progress - %d:%02d", minutes, seconds)
+ }
+ CallState.HELD -> "Call on hold"
+ CallState.DISCONNECTED -> "Call ended"
+ }
+ builder.setContentText(contentText)
+
+ // Add actions based on state
+ when (call.state) {
+ CallState.RINGING -> {
+ if (call.isIncoming) {
+ // Answer and Reject buttons for incoming calls
+ val answerIntent = Intent(this, LiveNotificationService::class.java).apply {
+ action = ACTION_ANSWER_CALL
+ putExtra(EXTRA_CALL_ID, call.id)
+ }
+ val answerPendingIntent = PendingIntent.getService(
+ this, call.id.hashCode(), answerIntent, PendingIntent.FLAG_IMMUTABLE
+ )
+
+ val rejectIntent = Intent(this, LiveNotificationService::class.java).apply {
+ action = ACTION_REJECT_CALL
+ putExtra(EXTRA_CALL_ID, call.id)
+ }
+ val rejectPendingIntent = PendingIntent.getService(
+ this, call.id.hashCode() + 1, rejectIntent, PendingIntent.FLAG_IMMUTABLE
+ )
+
+ builder.addAction(
+ R.drawable.outline_call_24,
+ "Answer",
+ answerPendingIntent
+ )
+ builder.addAction(
+ R.drawable.outline_call_end_24,
+ "Reject",
+ rejectPendingIntent
+ )
+
+ // Use full screen intent for incoming calls
+ builder.setFullScreenIntent(contentPendingIntent, true)
+ }
+ }
+ CallState.ACTIVE, CallState.HELD -> {
+ // End call button for active calls
+ val endIntent = Intent(this, LiveNotificationService::class.java).apply {
+ action = ACTION_END_CALL
+ putExtra(EXTRA_CALL_ID, call.id)
+ }
+ val endPendingIntent = PendingIntent.getService(
+ this, call.id.hashCode() + 2, endIntent, PendingIntent.FLAG_IMMUTABLE
+ )
+
+ builder.addAction(
+ R.drawable.outline_call_end_24,
+ "End Call",
+ endPendingIntent
+ )
+ }
+ CallState.DISCONNECTED -> {
+ // No actions for disconnected calls
+ }
+ }
+
+ // Use CallStyle for Android 12+ (API 31+)
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
+ val person = Person.Builder()
+ .setName(displayName)
+ .setImportant(true)
+ .build()
+
+ val callStyle = when (call.state) {
+ CallState.RINGING -> {
+ if (call.isIncoming) {
+ NotificationCompat.CallStyle.forIncomingCall(
+ person,
+ PendingIntent.getService(
+ this,
+ call.id.hashCode() + 1,
+ Intent(this, LiveNotificationService::class.java).apply {
+ action = ACTION_REJECT_CALL
+ putExtra(EXTRA_CALL_ID, call.id)
+ },
+ PendingIntent.FLAG_IMMUTABLE
+ ),
+ PendingIntent.getService(
+ this,
+ call.id.hashCode(),
+ Intent(this, LiveNotificationService::class.java).apply {
+ action = ACTION_ANSWER_CALL
+ putExtra(EXTRA_CALL_ID, call.id)
+ },
+ PendingIntent.FLAG_IMMUTABLE
+ )
+ )
+ } else {
+ NotificationCompat.CallStyle.forOngoingCall(
+ person,
+ PendingIntent.getService(
+ this,
+ call.id.hashCode() + 2,
+ Intent(this, LiveNotificationService::class.java).apply {
+ action = ACTION_END_CALL
+ putExtra(EXTRA_CALL_ID, call.id)
+ },
+ PendingIntent.FLAG_IMMUTABLE
+ )
+ )
+ }
+ }
+ CallState.ACTIVE, CallState.HELD -> {
+ NotificationCompat.CallStyle.forOngoingCall(
+ person,
+ PendingIntent.getService(
+ this,
+ call.id.hashCode() + 2,
+ Intent(this, LiveNotificationService::class.java).apply {
+ action = ACTION_END_CALL
+ putExtra(EXTRA_CALL_ID, call.id)
+ },
+ PendingIntent.FLAG_IMMUTABLE
+ )
+ )
+ }
+ CallState.DISCONNECTED -> {
+ NotificationCompat.CallStyle.forOngoingCall(person, contentPendingIntent)
+ }
+ }
+
+ builder.setStyle(callStyle)
+ }
+
+ return builder.build()
+ }
+
+ private fun createFallbackCallNotification(call: OngoingCall): Notification {
+ val displayName = call.contactName ?: call.number
+ val contentIntent = Intent(this, MainActivity::class.java)
+ val contentPendingIntent = PendingIntent.getActivity(
+ this, 0, contentIntent, PendingIntent.FLAG_IMMUTABLE
+ )
+
+ val builder = NotificationCompat.Builder(this, CHANNEL_CALL)
+ .setSmallIcon(R.drawable.ic_launcher_foreground)
+ .setContentTitle("Call - $displayName")
+ .setContentText(when (call.state) {
+ CallState.RINGING -> if (call.isIncoming) "Incoming call" else "Outgoing call"
+ CallState.ACTIVE -> "Call in progress"
+ CallState.HELD -> "Call on hold"
+ CallState.DISCONNECTED -> "Call ended"
+ })
+ .setContentIntent(contentPendingIntent)
+ .setOngoing(call.state != CallState.DISCONNECTED)
+ .setAutoCancel(call.state == CallState.DISCONNECTED)
+ .setCategory(NotificationCompat.CATEGORY_CALL)
+ .setPriority(NotificationCompat.PRIORITY_HIGH)
+ .setDefaults(NotificationCompat.DEFAULT_ALL)
+
+ return builder.build()
+ }
+
+ private fun storeAlbumArt(callId: String, albumArt: Bitmap?) {
+ if (albumArt != null) {
+ albumArtCache[callId] = albumArt
+ } else {
+ albumArtCache.remove(callId)
+ }
+ }
+
+ override fun onDestroy() {
+ super.onDestroy()
+ instance = null
+ albumArtCache.clear()
+ activeNotifications.clear()
+ Log.d(TAG, "LiveNotificationService destroyed")
+ }
+}
diff --git a/app/src/main/java/com/sameerasw/airsync/service/MacMediaPlayerService.kt b/app/src/main/java/com/sameerasw/airsync/service/MacMediaPlayerService.kt
index a680d28..f1816bf 100644
--- a/app/src/main/java/com/sameerasw/airsync/service/MacMediaPlayerService.kt
+++ b/app/src/main/java/com/sameerasw/airsync/service/MacMediaPlayerService.kt
@@ -303,12 +303,9 @@ class MacMediaPlayerService : Service() {
private fun sendMacMediaControl(action: String) {
try {
- // Check if we should send media control to prevent feedback loop
- if (!WebSocketMessageHandler.shouldSendMediaControl()) {
- Log.d(TAG, "Skipping media control '$action' - currently receiving playing media from Mac")
- return
- }
-
+ // Always send user-initiated control commands (play/pause/next/previous/stop)
+ // The shouldSendMediaControl check is only for preventing duplicate state updates,
+ // not for blocking user controls
val controlJson = """{"type":"macMediaControl","data":{"action":"$action"}}"""
com.sameerasw.airsync.utils.WebSocketUtil.sendMessage(controlJson)
Log.d(TAG, "Sent Mac media control: $action")
diff --git a/app/src/main/java/com/sameerasw/airsync/service/ScreenCaptureService.kt b/app/src/main/java/com/sameerasw/airsync/service/ScreenCaptureService.kt
new file mode 100644
index 0000000..551d957
--- /dev/null
+++ b/app/src/main/java/com/sameerasw/airsync/service/ScreenCaptureService.kt
@@ -0,0 +1,374 @@
+
+package com.sameerasw.airsync.service
+
+import android.app.Activity
+import android.app.Notification
+import android.app.NotificationChannel
+import android.app.NotificationManager
+import android.app.PendingIntent
+import android.app.Service
+import android.content.Context
+import android.content.Intent
+import android.content.pm.ServiceInfo
+import android.media.MediaCodec
+import android.media.projection.MediaProjection
+import android.media.projection.MediaProjectionManager
+import android.os.Build
+import android.os.Handler
+import android.os.HandlerThread
+import android.os.IBinder
+import android.util.Base64
+import android.util.Log
+import com.sameerasw.airsync.R
+import com.sameerasw.airsync.utils.JsonUtil
+import com.sameerasw.airsync.utils.RawFrameEncoder
+import com.sameerasw.airsync.utils.ScreenMirroringManager
+import com.sameerasw.airsync.utils.WebSocketUtil
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.launch
+
+class ScreenCaptureService : Service() {
+
+ private var mediaProjection: MediaProjection? = null
+ private var screenMirroringManager: ScreenMirroringManager? = null
+ private var rawFrameEncoder: RawFrameEncoder? = null
+ private var audioCaptureService: AudioCaptureService? = null
+ private var useRawFrames = true // Use raw frames by default, H.264 as fallback
+ private var enableAudio = false // Audio mirroring flag
+
+ private var handlerThread: HandlerThread? = null
+ private var backgroundHandler: Handler? = null
+
+ // Black overlay for hiding screen content
+ private var blackOverlayView: android.view.View? = null
+ private var windowManager: android.view.WindowManager? = null
+
+ private val mediaProjectionCallback = object : MediaProjection.Callback() {
+ override fun onStop() {
+ super.onStop()
+ Log.d(TAG, "MediaProjection session stopped by user.")
+ stopMirroring()
+ }
+ }
+
+ companion object {
+ private const val TAG = "ScreenCaptureService"
+ private const val NOTIFICATION_ID = 123
+ private const val NOTIFICATION_CHANNEL_ID = "ScreenCaptureChannel"
+ const val ACTION_START = "com.sameerasw.airsync.service.ScreenCaptureService.START"
+ const val ACTION_STOP = "com.sameerasw.airsync.service.ScreenCaptureService.STOP"
+ const val EXTRA_RESULT_CODE = "resultCode"
+ const val EXTRA_DATA = "data"
+ const val EXTRA_MIRRORING_OPTIONS = "mirroringOptions"
+
+ private val _isStreaming = MutableStateFlow(false)
+ val isStreaming = _isStreaming.asStateFlow()
+
+ var instance: ScreenCaptureService? = null
+ }
+
+ override fun onBind(intent: Intent?): IBinder? = null
+
+ override fun onCreate() {
+ super.onCreate()
+ instance = this
+ createNotificationChannel()
+ handlerThread = HandlerThread("ScreenCaptureThread").also { it.start() }
+ backgroundHandler = Handler(handlerThread!!.looper)
+ }
+
+ override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
+ when (intent?.action) {
+ ACTION_START -> {
+ // Prevent duplicate mirroring sessions
+ if (_isStreaming.value) {
+ Log.w(TAG, "Screen mirroring already active, ignoring duplicate start request")
+ return START_NOT_STICKY
+ }
+
+ startForegroundWithServiceType()
+ val resultCode = intent.getIntExtra(EXTRA_RESULT_CODE, Activity.RESULT_CANCELED)
+ val data = intent.getParcelableExtra(EXTRA_DATA)
+ val mirroringOptions = intent.getParcelableExtra(EXTRA_MIRRORING_OPTIONS)
+
+ if (resultCode == Activity.RESULT_OK && data != null && mirroringOptions != null) {
+ _isStreaming.value = true
+ // Reset pending flag now that mirroring has started
+ com.sameerasw.airsync.utils.MirrorRequestHelper.resetPendingFlag()
+ initializeMediaProjection(resultCode, data, mirroringOptions)
+ if (useRawFrames) {
+ rawFrameEncoder?.startCapture()
+ } else {
+ screenMirroringManager?.startMirroring()
+ }
+ } else {
+ Log.e(TAG, "Invalid start parameters for screen capture. Stopping service.")
+ com.sameerasw.airsync.utils.MirrorRequestHelper.resetPendingFlag()
+ stopForeground(STOP_FOREGROUND_REMOVE)
+ stopSelf()
+ }
+ }
+ ACTION_STOP -> {
+ stopMirroring()
+ }
+ }
+ return START_NOT_STICKY
+ }
+
+ private fun startForegroundWithServiceType() {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
+ startForeground(NOTIFICATION_ID, createNotification(), ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PROJECTION)
+ } else {
+ startForeground(NOTIFICATION_ID, createNotification())
+ }
+ }
+
+ private fun initializeMediaProjection(resultCode: Int, data: Intent, mirroringOptions: com.sameerasw.airsync.domain.model.MirroringOptions) {
+ val mediaProjectionManager = getSystemService(Context.MEDIA_PROJECTION_SERVICE) as MediaProjectionManager
+ mediaProjection = mediaProjectionManager.getMediaProjection(resultCode, data)
+ mediaProjection?.registerCallback(mediaProjectionCallback, backgroundHandler)
+
+ // Check if we should use raw frames or H.264
+ useRawFrames = mirroringOptions.useRawFrames ?: true
+ enableAudio = mirroringOptions.enableAudio
+
+ if (useRawFrames) {
+ Log.d(TAG, "Using raw frame encoder (JPEG)")
+ rawFrameEncoder = RawFrameEncoder(this, mediaProjection!!, backgroundHandler!!, ::sendRawFrame, mirroringOptions)
+ } else {
+ Log.d(TAG, "Using H.264 encoder (fallback)")
+ screenMirroringManager = ScreenMirroringManager(this, mediaProjection!!, backgroundHandler!!, ::sendMirrorFrame, mirroringOptions)
+ }
+
+ // Initialize audio capture if enabled (Android 10+)
+ if (enableAudio && Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
+ Log.d(TAG, "Initializing audio capture")
+ audioCaptureService = AudioCaptureService(this, mediaProjection!!)
+ if (audioCaptureService?.isSupported() == true) {
+ audioCaptureService?.startCapture()
+ } else {
+ Log.w(TAG, "Audio capture not supported on this device")
+ }
+ } else if (enableAudio) {
+ Log.w(TAG, "Audio capture requires Android 10+ (API 29)")
+ }
+
+ // Send mirrorStart message to Mac
+ sendMirrorStart(mirroringOptions)
+ }
+
+ private fun sendMirrorStart(options: com.sameerasw.airsync.domain.model.MirroringOptions) {
+ CoroutineScope(Dispatchers.IO).launch {
+ try {
+ // Get actual screen dimensions
+ val displayMetrics = resources.displayMetrics
+ val width = minOf(displayMetrics.widthPixels, options.maxWidth)
+ val height = (displayMetrics.heightPixels * width) / displayMetrics.widthPixels
+
+ val json = JsonUtil.createMirrorStartJson(
+ fps = options.fps,
+ quality = options.quality,
+ width = width,
+ height = height
+ )
+ WebSocketUtil.sendMessage(json)
+ Log.d(TAG, "Sent mirrorStart: fps=${options.fps}, quality=${options.quality}, width=$width, height=$height")
+ } catch (e: Exception) {
+ Log.e(TAG, "Error sending mirrorStart", e)
+ }
+ }
+ }
+
+ fun stopMirroring() {
+ if (!_isStreaming.compareAndSet(expect = true, update = false)) return
+ Log.d(TAG, "Stopping screen capture.")
+
+ // Stop audio capture first
+ audioCaptureService?.stopCapture()
+ audioCaptureService = null
+
+ rawFrameEncoder?.stopCapture()
+ rawFrameEncoder = null
+
+ screenMirroringManager?.stopMirroring()
+ screenMirroringManager = null
+
+ mediaProjection?.unregisterCallback(mediaProjectionCallback)
+ mediaProjection?.stop()
+ mediaProjection = null
+
+ // Send mirrorStop message to Mac
+ sendMirrorStop()
+
+ stopForeground(STOP_FOREGROUND_REMOVE)
+ stopSelf()
+ }
+
+ private fun sendMirrorStop() {
+ CoroutineScope(Dispatchers.IO).launch {
+ try {
+ val json = """{"type":"mirrorStop","data":{}}"""
+ WebSocketUtil.sendMessage(json)
+ Log.d(TAG, "Sent mirrorStop to Mac")
+ } catch (e: Exception) {
+ Log.e(TAG, "Error sending mirrorStop", e)
+ }
+ }
+ }
+
+ fun resendConfig() {
+ backgroundHandler?.post {
+ screenMirroringManager?.resendConfig()
+ }
+ }
+
+ private fun sendMirrorFrame(frame: ByteArray, bufferInfo: MediaCodec.BufferInfo) {
+ CoroutineScope(Dispatchers.IO).launch {
+ val isConfig = (bufferInfo.flags and MediaCodec.BUFFER_FLAG_CODEC_CONFIG) != 0
+ val base64Frame = Base64.encodeToString(frame, Base64.NO_WRAP)
+ val json = JsonUtil.createMirrorFrameJson(base64Frame, bufferInfo.presentationTimeUs, isConfig)
+ WebSocketUtil.sendMessage(json)
+ }
+ }
+
+ private fun sendRawFrame(frame: ByteArray, metadata: RawFrameEncoder.FrameMetadata) {
+ CoroutineScope(Dispatchers.IO).launch {
+ val base64Frame = Base64.encodeToString(frame, Base64.NO_WRAP)
+ val json = """{"type":"mirrorFrame","data":{"frame":"$base64Frame","format":"${metadata.format}","timestamp":${metadata.timestamp},"isConfig":false}}"""
+ WebSocketUtil.sendMessage(json)
+ }
+ }
+
+ private fun createNotificationChannel() {
+ val channel = NotificationChannel(
+ NOTIFICATION_CHANNEL_ID, "Screen Capture",
+ NotificationManager.IMPORTANCE_DEFAULT // Changed from LOW to show actions
+ ).apply {
+ description = "Shows when screen mirroring is active"
+ setShowBadge(false)
+ }
+ val manager = getSystemService(NotificationManager::class.java)
+ manager.createNotificationChannel(channel)
+ }
+
+ private fun createNotification(): Notification {
+ val stopIntent = Intent(this, ScreenCaptureService::class.java).apply {
+ action = ACTION_STOP
+ }
+ val pendingStopIntent = PendingIntent.getService(this, 0, stopIntent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
+
+ val builder = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ Notification.Builder(this, NOTIFICATION_CHANNEL_ID)
+ } else {
+ @Suppress("DEPRECATION")
+ Notification.Builder(this)
+ }
+
+ return builder
+ .setContentTitle("AirSync Mirror")
+ .setContentText("Screen mirroring is active")
+ .setSmallIcon(R.drawable.ic_launcher_foreground)
+ .addAction(
+ Notification.Action.Builder(
+ android.R.drawable.ic_menu_close_clear_cancel,
+ "Stop Mirroring",
+ pendingStopIntent
+ ).build()
+ )
+ .setOngoing(true)
+ .setCategory(Notification.CATEGORY_SERVICE)
+ .setVisibility(Notification.VISIBILITY_PUBLIC)
+ .setStyle(Notification.BigTextStyle()
+ .bigText("Screen mirroring is active. Tap 'Stop Mirroring' button below to end the session."))
+ .build()
+ }
+
+ fun showBlackOverlay() {
+ try {
+ if (blackOverlayView != null) {
+ Log.d(TAG, "Black overlay already shown")
+ return
+ }
+
+ // Check if we have overlay permission
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
+ if (!android.provider.Settings.canDrawOverlays(this)) {
+ Log.e(TAG, "❌ No SYSTEM_ALERT_WINDOW permission - cannot show overlay")
+ Log.e(TAG, "💡 Enable 'Display over other apps' in Settings → Apps → AirSync → Advanced")
+ return
+ }
+ }
+
+ windowManager = getSystemService(Context.WINDOW_SERVICE) as android.view.WindowManager
+
+ // Create black overlay view (screen curtain)
+ blackOverlayView = android.view.View(this).apply {
+ setBackgroundColor(android.graphics.Color.BLACK)
+ // Make it completely opaque
+ alpha = 1.0f
+ }
+
+ // Set up window parameters for full-screen overlay (screen curtain)
+ val params = android.view.WindowManager.LayoutParams(
+ android.view.WindowManager.LayoutParams.MATCH_PARENT,
+ android.view.WindowManager.LayoutParams.MATCH_PARENT,
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ android.view.WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY
+ } else {
+ @Suppress("DEPRECATION")
+ android.view.WindowManager.LayoutParams.TYPE_SYSTEM_ALERT
+ },
+ // Screen curtain flags: not focusable, not touchable, covers everything
+ android.view.WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE or
+ android.view.WindowManager.LayoutParams.FLAG_NOT_TOUCHABLE or
+ android.view.WindowManager.LayoutParams.FLAG_LAYOUT_IN_SCREEN or
+ android.view.WindowManager.LayoutParams.FLAG_FULLSCREEN or
+ android.view.WindowManager.LayoutParams.FLAG_LAYOUT_NO_LIMITS or
+ android.view.WindowManager.LayoutParams.FLAG_HARDWARE_ACCELERATED,
+ android.graphics.PixelFormat.OPAQUE
+ )
+
+ // Position at top-left, covering entire screen
+ params.x = 0
+ params.y = 0
+ params.gravity = android.view.Gravity.TOP or android.view.Gravity.START
+
+ // Highest priority to ensure it's on top
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ params.layoutInDisplayCutoutMode = android.view.WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES
+ }
+
+ // Add overlay to window
+ windowManager?.addView(blackOverlayView, params)
+ Log.d(TAG, "✅ Screen curtain (black overlay) shown successfully")
+ } catch (e: Exception) {
+ Log.e(TAG, "❌ Error showing screen curtain: ${e.message}", e)
+ e.printStackTrace()
+ }
+ }
+
+ fun hideBlackOverlay() {
+ try {
+ blackOverlayView?.let { view ->
+ windowManager?.removeView(view)
+ blackOverlayView = null
+ Log.d(TAG, "✅ Black overlay hidden successfully")
+ } ?: run {
+ Log.d(TAG, "ℹ️ Black overlay already hidden")
+ }
+ } catch (e: Exception) {
+ Log.e(TAG, "❌ Error hiding black overlay: ${e.message}", e)
+ }
+ }
+
+ override fun onDestroy() {
+ hideBlackOverlay() // Clean up overlay on service destroy
+ stopMirroring()
+ handlerThread?.quitSafely()
+ instance = null
+ super.onDestroy()
+ }
+}
diff --git a/app/src/main/java/com/sameerasw/airsync/service/WakeupService.kt b/app/src/main/java/com/sameerasw/airsync/service/WakeupService.kt
index 3131ec6..139f64d 100644
--- a/app/src/main/java/com/sameerasw/airsync/service/WakeupService.kt
+++ b/app/src/main/java/com/sameerasw/airsync/service/WakeupService.kt
@@ -301,7 +301,7 @@ class WakeupService : Service() {
private suspend fun processWakeupRequest(macIp: String, macPort: Int, macName: String) {
try {
- Log.i(TAG, "Processing wake-up request from $macName at $macIp:$macPort")
+ Log.i(TAG, "📞 Received wake-up request from $macName at $macIp:$macPort")
// Validate that we have the necessary information
if (macIp.isEmpty()) {
@@ -313,7 +313,7 @@ class WakeupService : Service() {
// Check if we already have a connection
if (WebSocketUtil.isConnected()) {
- Log.d(TAG, "Already connected, ignoring wake-up request")
+ Log.d(TAG, "Already connected, sending acknowledgment")
return
}
@@ -359,8 +359,8 @@ class WakeupService : Service() {
dataStoreManager.saveLastConnectedDevice(connectedDevice)
}
- // Attempt to connect to the Mac
- Log.d(TAG, "Attempting to connect to Mac at $macIp:$macPort")
+ // Attempt to connect to the Mac - NO CHECKING, JUST CONNECT
+ Log.d(TAG, "🚀 Connecting to Mac at $macIp:$macPort (no pre-check)")
WebSocketUtil.connect(
context = this@WakeupService,
@@ -370,7 +370,7 @@ class WakeupService : Service() {
manualAttempt = false, // This is an automated response to wake-up
onConnectionStatus = { connected ->
if (connected) {
- Log.i(TAG, "Successfully connected to Mac after wake-up request")
+ Log.i(TAG, "✅ Successfully connected to Mac after wake-up request")
// Update last connected timestamp
CoroutineScope(Dispatchers.IO).launch {
try {
@@ -380,11 +380,11 @@ class WakeupService : Service() {
}
}
} else {
- Log.w(TAG, "Failed to connect to Mac after wake-up request")
+ Log.w(TAG, "❌ Failed to connect to Mac after wake-up request")
}
},
onHandshakeTimeout = {
- Log.w(TAG, "Handshake timeout during wake-up connection attempt")
+ Log.w(TAG, "⏱️ Handshake timeout during wake-up connection attempt")
WebSocketUtil.disconnect(this@WakeupService)
}
)
diff --git a/app/src/main/java/com/sameerasw/airsync/smartspacer/AirSyncDeviceTarget.kt b/app/src/main/java/com/sameerasw/airsync/smartspacer/AirSyncDeviceTarget.kt
index bd15d89..8f1fe2d 100644
--- a/app/src/main/java/com/sameerasw/airsync/smartspacer/AirSyncDeviceTarget.kt
+++ b/app/src/main/java/com/sameerasw/airsync/smartspacer/AirSyncDeviceTarget.kt
@@ -4,6 +4,7 @@ import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.graphics.drawable.Icon
+import android.os.Build
import com.kieronquinn.app.smartspacer.sdk.model.CompatibilityState
import com.kieronquinn.app.smartspacer.sdk.model.SmartspaceTarget
import com.kieronquinn.app.smartspacer.sdk.model.uitemplatedata.TapAction
@@ -22,102 +23,122 @@ import kotlinx.coroutines.flow.first
class AirSyncDeviceTarget : SmartspacerTargetProvider() {
override fun getSmartspaceTargets(smartspacerId: String): List {
- val context = provideContext()
- val isConnected = WebSocketUtil.isConnected()
-
- // Get the Smartspacer setting
- val showWhenDisconnected = runBlocking {
- DataStoreManager(context).getSmartspacerShowWhenDisconnected().first()
- }
+ return try {
+ val context = provideContext()
+ val isConnected = WebSocketUtil.isConnected()
+ val dataStoreManager = DataStoreManager.getInstance(context)
+
+ // Get the Smartspacer setting
+ val showWhenDisconnected = runBlocking {
+ dataStoreManager.getSmartspacerShowWhenDisconnected().first()
+ }
- // Get last connected device info
- val lastDevice = runBlocking {
- DataStoreManager(context).getLastConnectedDevice().first()
- }
+ // Get last connected device info
+ val lastDevice = runBlocking {
+ dataStoreManager.getLastConnectedDevice().first()
+ }
- // Don't show target if never connected
- if (lastDevice == null && !isConnected) {
- return emptyList()
- }
+ // Don't show target if never connected
+ if (lastDevice == null && !isConnected) {
+ return emptyList()
+ }
- // If not connected and user disabled "show when disconnected", hide the target
- if (!isConnected && !showWhenDisconnected) {
- return emptyList()
- }
+ // If not connected and user disabled "show when disconnected", hide the target
+ if (!isConnected && !showWhenDisconnected) {
+ return emptyList()
+ }
- val deviceName = lastDevice?.name ?: "Unknown Device"
- val deviceModel = lastDevice?.model
+ val deviceName = lastDevice?.name ?: "Unknown Device"
+ val deviceModel = lastDevice?.model
- // Get Mac status (battery and media info)
- val macStatus = runBlocking {
- DataStoreManager(context).getMacStatusForWidget().first()
- }
+ // Get Mac status (battery and media info)
+ val macStatus = runBlocking {
+ dataStoreManager.getMacStatusForWidget().first()
+ }
- // Build subtitle with battery, media info, or device model
- val subtitle = when {
- isConnected && macStatus.batteryLevel != null -> {
- // Show battery percentage when connected
- val batteryText = "${macStatus.batteryLevel}%"
- // Add media info if available
- if (!macStatus.title.isNullOrBlank()) {
- if (!macStatus.artist.isNullOrBlank()) {
- "$batteryText • ${macStatus.title} — ${macStatus.artist}"
+ // Build subtitle with battery, media info, or device model
+ val subtitle = when {
+ isConnected && macStatus.batteryLevel != null -> {
+ // Show battery percentage when connected
+ val batteryText = "${macStatus.batteryLevel}%"
+ // Add media info if available
+ if (!macStatus.title.isNullOrBlank()) {
+ if (!macStatus.artist.isNullOrBlank()) {
+ "$batteryText • ${macStatus.title} — ${macStatus.artist}"
+ } else {
+ "$batteryText • ${macStatus.title}"
+ }
} else {
- "$batteryText • ${macStatus.title}"
+ batteryText
}
- } else {
- batteryText
}
- }
- isConnected && !macStatus.title.isNullOrBlank() -> {
- // Show media info only if no battery
- if (!macStatus.artist.isNullOrBlank()) {
- "${macStatus.title} — ${macStatus.artist}"
- } else {
- macStatus.title
+ isConnected && !macStatus.title.isNullOrBlank() -> {
+ // Show media info only if no battery
+ if (!macStatus.artist.isNullOrBlank()) {
+ "${macStatus.title} — ${macStatus.artist}"
+ } else {
+ macStatus.title
+ }
}
+ isConnected -> "Connected"
+ deviceModel != null -> "Disconnected • $deviceModel"
+ else -> "Disconnected • Tap to reconnect"
}
- isConnected -> "Connected"
- deviceModel != null -> "Disconnected • $deviceModel"
- else -> "Disconnected • Tap to reconnect"
- }
- // Use DeviceIconResolver for the small icon (shown on right)
- val iconRes = DeviceIconResolver.getIconRes(lastDevice)
+ // Use DeviceIconResolver for the small icon (shown on right)
+ val iconRes = DeviceIconResolver.getIconRes(lastDevice)
- // Use DevicePreviewResolver for the large device preview image (shown on left)
- val deviceImageRes = DevicePreviewResolver.getPreviewRes(lastDevice)
+ // Use DevicePreviewResolver for the large device preview image (shown on left)
+ val deviceImageRes = DevicePreviewResolver.getPreviewRes(lastDevice)
- val tapIntent = if (isConnected) {
- // When connected, open the app
- Intent(context, MainActivity::class.java).apply {
- flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP
+ val tapIntent = Intent(context, MainActivity::class.java).apply {
+ flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP
}
- } else {
- Intent(context, MainActivity::class.java).apply {
- flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP
+
+ // Use Basic template for Samsung One UI compatibility (Image template may not work on all Samsung devices)
+ val isSamsungDevice = Build.MANUFACTURER.equals("samsung", ignoreCase = true)
+
+ val target = if (isSamsungDevice) {
+ // Use Basic template for Samsung - more compatible with One UI
+ TargetTemplate.Basic(
+ id = "airsync_device_$smartspacerId",
+ componentName = ComponentName(context, AirSyncDeviceTarget::class.java),
+ title = Text(deviceName),
+ subtitle = Text(subtitle),
+ icon = com.kieronquinn.app.smartspacer.sdk.model.uitemplatedata.Icon(
+ Icon.createWithResource(context, iconRes)
+ ),
+ onClick = TapAction(intent = tapIntent)
+ ).create().apply {
+ canBeDismissed = false
+ isSensitive = false
+ }
+ } else {
+ // Use Image template for other devices
+ TargetTemplate.Image(
+ context = context,
+ id = "airsync_device_$smartspacerId",
+ componentName = ComponentName(context, AirSyncDeviceTarget::class.java),
+ title = Text(deviceName),
+ subtitle = Text(subtitle),
+ icon = com.kieronquinn.app.smartspacer.sdk.model.uitemplatedata.Icon(
+ Icon.createWithResource(context, iconRes)
+ ),
+ image = com.kieronquinn.app.smartspacer.sdk.model.uitemplatedata.Icon(
+ Icon.createWithResource(context, deviceImageRes)
+ ),
+ onClick = TapAction(intent = tapIntent)
+ ).create().apply {
+ canBeDismissed = false
+ isSensitive = false
+ }
}
- }
- val target = TargetTemplate.Image(
- context = context,
- id = "airsync_device_$smartspacerId",
- componentName = ComponentName(context, AirSyncDeviceTarget::class.java),
- title = Text(deviceName),
- subtitle = Text(subtitle),
- icon = com.kieronquinn.app.smartspacer.sdk.model.uitemplatedata.Icon(
- Icon.createWithResource(context, iconRes)
- ),
- image = com.kieronquinn.app.smartspacer.sdk.model.uitemplatedata.Icon(
- Icon.createWithResource(context, deviceImageRes)
- ),
- onClick = TapAction(intent = tapIntent)
- ).create().apply {
- canBeDismissed = false
- isSensitive = false
+ listOf(target)
+ } catch (e: Exception) {
+ e.printStackTrace()
+ emptyList()
}
-
- return listOf(target)
}
override fun getConfig(smartspacerId: String?): Config {
diff --git a/app/src/main/java/com/sameerasw/airsync/utils/BluetoothHelper.kt b/app/src/main/java/com/sameerasw/airsync/utils/BluetoothHelper.kt
new file mode 100644
index 0000000..b65e6e5
--- /dev/null
+++ b/app/src/main/java/com/sameerasw/airsync/utils/BluetoothHelper.kt
@@ -0,0 +1,1280 @@
+package com.sameerasw.airsync.utils
+
+import android.Manifest
+import android.bluetooth.BluetoothAdapter
+import android.bluetooth.BluetoothDevice
+import android.bluetooth.BluetoothGatt
+import android.bluetooth.BluetoothGattCallback
+import android.bluetooth.BluetoothGattCharacteristic
+import android.bluetooth.BluetoothGattDescriptor
+import android.bluetooth.BluetoothGattServer
+import android.bluetooth.BluetoothGattServerCallback
+import android.bluetooth.BluetoothGattService
+import android.bluetooth.BluetoothManager
+import android.bluetooth.BluetoothProfile
+import android.bluetooth.le.AdvertiseCallback
+import android.bluetooth.le.AdvertiseData
+import android.bluetooth.le.AdvertiseSettings
+import android.bluetooth.le.BluetoothLeAdvertiser
+import android.bluetooth.le.BluetoothLeScanner
+import android.bluetooth.le.ScanCallback
+import android.bluetooth.le.ScanFilter
+import android.bluetooth.le.ScanResult
+import android.bluetooth.le.ScanSettings
+import android.content.Context
+import android.content.pm.PackageManager
+import android.os.Build
+import android.os.Handler
+import android.os.Looper
+import android.os.ParcelUuid
+import android.util.Log
+import androidx.core.content.ContextCompat
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.launch
+import org.json.JSONObject
+import java.nio.charset.StandardCharsets
+import java.util.UUID
+import java.util.concurrent.ConcurrentHashMap
+
+/**
+ * Advanced Bluetooth Low Energy helper for AirSync
+ * Provides:
+ * - BLE advertising for device discovery
+ * - BLE scanning to find nearby AirSync devices
+ * - GATT server/client for bidirectional data exchange
+ * - Connection management with auto-reconnect
+ * - Chunked data transfer for large payloads
+ *
+ * Inspired by LocalSend's discovery protocol
+ */
+class BluetoothHelper(private val context: Context) {
+
+ companion object {
+ private const val TAG = "BluetoothHelper"
+
+ // AirSync service UUID - must match Mac side
+ val AIRSYNC_SERVICE_UUID: UUID = UUID.fromString("A1B2C3D4-E5F6-7890-ABCD-EF1234567890")
+
+ // Characteristics
+ val DEVICE_INFO_CHAR_UUID: UUID = UUID.fromString("A1B2C3D4-E5F6-7890-ABCD-EF1234567891")
+ val DATA_TRANSFER_CHAR_UUID: UUID = UUID.fromString("A1B2C3D4-E5F6-7890-ABCD-EF1234567892")
+ val COMMAND_CHAR_UUID: UUID = UUID.fromString("A1B2C3D4-E5F6-7890-ABCD-EF1234567893")
+ val NOTIFICATION_CHAR_UUID: UUID = UUID.fromString("A1B2C3D4-E5F6-7890-ABCD-EF1234567894")
+
+ // Client Characteristic Configuration Descriptor
+ val CCCD_UUID: UUID = UUID.fromString("00002902-0000-1000-8000-00805f9b34fb")
+
+ // Data transfer constants
+ const val MAX_MTU = 512
+ const val DEFAULT_MTU = 23
+ const val CHUNK_SIZE = 500 // Leave room for headers
+ const val SCAN_TIMEOUT_MS = 30000L
+ const val RECONNECT_DELAY_MS = 5000L
+ const val MAX_RECONNECT_ATTEMPTS = 3
+ }
+
+ // State
+ private var bluetoothManager: BluetoothManager? = null
+ private var bluetoothAdapter: BluetoothAdapter? = null
+ private var bluetoothLeAdvertiser: BluetoothLeAdvertiser? = null
+ private var bluetoothLeScanner: BluetoothLeScanner? = null
+ private var gattServer: BluetoothGattServer? = null
+ private var isAdvertising = false
+ private var isScanning = false
+ private var currentMtu = DEFAULT_MTU
+
+ // Connection management
+ private val connectedDevices = ConcurrentHashMap()
+ private val deviceGatts = ConcurrentHashMap()
+ private val reconnectAttempts = ConcurrentHashMap()
+ private var reconnectJob: Job? = null
+
+ // Discovered devices
+ private val _discoveredDevices = MutableStateFlow>(emptyList())
+ val discoveredDevices: StateFlow> = _discoveredDevices
+
+ // Connection state
+ private val _connectionState = MutableStateFlow(ConnectionState.Disconnected)
+ val connectionState: StateFlow = _connectionState
+
+ // Data transfer state
+ private val pendingTransfers = ConcurrentHashMap()
+ private val receivedChunks = ConcurrentHashMap>()
+
+ // Callbacks
+ var onDeviceDiscovered: ((DiscoveredDevice) -> Unit)? = null
+ var onDeviceConnected: ((BluetoothDevice) -> Unit)? = null
+ var onDeviceDisconnected: ((BluetoothDevice) -> Unit)? = null
+ var onServicesDiscovered: ((BluetoothDevice) -> Unit)? = null // Called when GATT services are ready
+ var onDataReceived: ((String, ByteArray) -> Unit)? = null
+ var onCommandReceived: ((String, JSONObject) -> Unit)? = null
+ var onTransferProgress: ((String, Int, Int) -> Unit)? = null
+ var onTransferComplete: ((String, ByteArray) -> Unit)? = null
+ var onError: ((String) -> Unit)? = null
+
+ // Handler for main thread operations
+ private val mainHandler = Handler(Looper.getMainLooper())
+
+ /**
+ * Data classes
+ */
+ data class DiscoveredDevice(
+ val device: BluetoothDevice,
+ val name: String,
+ val rssi: Int,
+ val deviceInfo: DeviceInfo?,
+ val lastSeen: Long = System.currentTimeMillis()
+ )
+
+ data class DeviceInfo(
+ val alias: String,
+ val version: String,
+ val deviceModel: String?,
+ val deviceType: String,
+ val port: Int,
+ val protocol: String
+ )
+
+ data class DataTransfer(
+ val id: String,
+ val totalSize: Int,
+ val totalChunks: Int,
+ var receivedChunks: Int = 0,
+ val data: MutableList = mutableListOf()
+ )
+
+ sealed class ConnectionState {
+ object Disconnected : ConnectionState()
+ object Connecting : ConnectionState()
+ object Connected : ConnectionState()
+ data class Error(val message: String) : ConnectionState()
+ }
+
+ /**
+ * Initialize Bluetooth
+ */
+ fun initialize(): Boolean {
+ bluetoothManager = context.getSystemService(Context.BLUETOOTH_SERVICE) as? BluetoothManager
+ bluetoothAdapter = bluetoothManager?.adapter
+
+ if (bluetoothAdapter == null) {
+ Log.e(TAG, "Bluetooth not supported on this device")
+ return false
+ }
+
+ if (!hasBluetoothPermissions()) {
+ Log.e(TAG, "Bluetooth permissions not granted")
+ return false
+ }
+
+ if (!bluetoothAdapter!!.isEnabled) {
+ Log.e(TAG, "Bluetooth is not enabled")
+ return false
+ }
+
+ bluetoothLeAdvertiser = bluetoothAdapter?.bluetoothLeAdvertiser
+ bluetoothLeScanner = bluetoothAdapter?.bluetoothLeScanner
+
+ if (bluetoothLeAdvertiser == null) {
+ Log.w(TAG, "BLE advertising not supported")
+ }
+
+ if (bluetoothLeScanner == null) {
+ Log.w(TAG, "BLE scanning not supported")
+ }
+
+ Log.d(TAG, "✅ Bluetooth initialized successfully")
+ return true
+ }
+
+ /**
+ * Check if Bluetooth permissions are granted
+ */
+ fun hasBluetoothPermissions(): Boolean {
+ return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
+ ContextCompat.checkSelfPermission(context, Manifest.permission.BLUETOOTH_ADVERTISE) == PackageManager.PERMISSION_GRANTED &&
+ ContextCompat.checkSelfPermission(context, Manifest.permission.BLUETOOTH_CONNECT) == PackageManager.PERMISSION_GRANTED &&
+ ContextCompat.checkSelfPermission(context, Manifest.permission.BLUETOOTH_SCAN) == PackageManager.PERMISSION_GRANTED
+ } else {
+ ContextCompat.checkSelfPermission(context, Manifest.permission.BLUETOOTH) == PackageManager.PERMISSION_GRANTED &&
+ ContextCompat.checkSelfPermission(context, Manifest.permission.BLUETOOTH_ADMIN) == PackageManager.PERMISSION_GRANTED &&
+ ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED
+ }
+ }
+
+ /**
+ * Get required permissions based on Android version
+ */
+ fun getRequiredPermissions(): Array {
+ return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
+ arrayOf(
+ Manifest.permission.BLUETOOTH_ADVERTISE,
+ Manifest.permission.BLUETOOTH_CONNECT,
+ Manifest.permission.BLUETOOTH_SCAN
+ )
+ } else {
+ arrayOf(
+ Manifest.permission.BLUETOOTH,
+ Manifest.permission.BLUETOOTH_ADMIN,
+ Manifest.permission.ACCESS_FINE_LOCATION
+ )
+ }
+ }
+
+
+ // ==================== ADVERTISING ====================
+
+ /**
+ * Start BLE advertising so other devices can discover this device
+ * Note: BLE advertising has a strict 31-byte limit per packet
+ */
+ fun startAdvertising(deviceInfo: DeviceInfo? = null) {
+ if (isAdvertising) {
+ Log.d(TAG, "Already advertising")
+ return
+ }
+
+ if (!hasBluetoothPermissions()) {
+ Log.e(TAG, "Cannot advertise - permissions not granted")
+ onError?.invoke("Bluetooth permissions not granted")
+ return
+ }
+
+ if (bluetoothLeAdvertiser == null) {
+ Log.e(TAG, "BLE advertising not supported")
+ onError?.invoke("BLE advertising not supported on this device")
+ return
+ }
+
+ try {
+ // Set up GATT server first
+ setupGattServer(deviceInfo)
+
+ // Configure advertising settings for optimal discovery
+ val settings = AdvertiseSettings.Builder()
+ .setAdvertiseMode(AdvertiseSettings.ADVERTISE_MODE_LOW_LATENCY)
+ .setTxPowerLevel(AdvertiseSettings.ADVERTISE_TX_POWER_HIGH)
+ .setConnectable(true)
+ .setTimeout(0) // Advertise indefinitely
+ .build()
+
+ // Configure advertising data - MINIMAL to stay under 31 bytes
+ // Service UUID (16 bytes) + flags (3 bytes) = 19 bytes, leaving room for overhead
+ val data = AdvertiseData.Builder()
+ .setIncludeDeviceName(false) // Don't include name - it's too long
+ .setIncludeTxPowerLevel(false) // Save space
+ .addServiceUuid(ParcelUuid(AIRSYNC_SERVICE_UUID))
+ .build()
+
+ // Scan response can include device name (separate 31-byte packet)
+ val scanResponse = AdvertiseData.Builder()
+ .setIncludeDeviceName(true) // Name goes in scan response
+ .build()
+
+ // Start advertising
+ bluetoothLeAdvertiser?.startAdvertising(settings, data, scanResponse, advertiseCallback)
+
+ Log.d(TAG, "📡 Starting BLE advertising...")
+ } catch (e: SecurityException) {
+ Log.e(TAG, "Security exception starting advertising", e)
+ onError?.invoke("Security exception: ${e.message}")
+ } catch (e: Exception) {
+ Log.e(TAG, "Error starting advertising", e)
+ onError?.invoke("Error starting advertising: ${e.message}")
+ }
+ }
+
+ /**
+ * Stop BLE advertising
+ */
+ fun stopAdvertising() {
+ if (!isAdvertising) return
+
+ try {
+ bluetoothLeAdvertiser?.stopAdvertising(advertiseCallback)
+ isAdvertising = false
+ Log.d(TAG, "🛑 Stopped BLE advertising")
+ } catch (e: SecurityException) {
+ Log.e(TAG, "Security exception stopping advertising", e)
+ } catch (e: Exception) {
+ Log.e(TAG, "Error stopping advertising", e)
+ }
+ }
+
+ // ==================== SCANNING ====================
+
+ /**
+ * Start scanning for nearby AirSync devices
+ */
+ fun startScanning(timeoutMs: Long = SCAN_TIMEOUT_MS) {
+ if (isScanning) {
+ Log.d(TAG, "Already scanning")
+ return
+ }
+
+ if (!hasBluetoothPermissions()) {
+ Log.e(TAG, "Cannot scan - permissions not granted")
+ onError?.invoke("Bluetooth permissions not granted")
+ return
+ }
+
+ if (bluetoothLeScanner == null) {
+ Log.e(TAG, "BLE scanning not supported")
+ onError?.invoke("BLE scanning not supported on this device")
+ return
+ }
+
+ try {
+ // Clear previous discoveries
+ _discoveredDevices.value = emptyList()
+
+ // Configure scan settings for aggressive discovery
+ val settings = ScanSettings.Builder()
+ .setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY)
+ .setReportDelay(0)
+ .build()
+
+ // Scan with AirSync service filter ONLY
+ val filters = listOf(
+ ScanFilter.Builder()
+ .setServiceUuid(ParcelUuid(AIRSYNC_SERVICE_UUID))
+ .build()
+ )
+
+ bluetoothLeScanner?.startScan(filters, settings, scanCallback)
+ isScanning = true
+ _connectionState.value = ConnectionState.Disconnected
+
+ Log.d(TAG, "🔍 Started BLE scanning for AirSync devices only...")
+
+ // Auto-stop after timeout
+ mainHandler.postDelayed({
+ if (isScanning) {
+ stopScanning()
+ Log.d(TAG, "⏱️ Scan timeout reached")
+ }
+ }, timeoutMs)
+
+ } catch (e: SecurityException) {
+ Log.e(TAG, "Security exception starting scan", e)
+ onError?.invoke("Security exception: ${e.message}")
+ } catch (e: Exception) {
+ Log.e(TAG, "Error starting scan", e)
+ onError?.invoke("Error starting scan: ${e.message}")
+ }
+ }
+
+ /**
+ * Start scanning without any filters (finds all BLE devices) - for debugging only
+ */
+ fun startScanningAll(timeoutMs: Long = SCAN_TIMEOUT_MS) {
+ // Just call the regular scan - it now filters in the callback
+ startScanning(timeoutMs)
+ }
+
+ /**
+ * Stop scanning
+ */
+ fun stopScanning() {
+ if (!isScanning) return
+
+ try {
+ bluetoothLeScanner?.stopScan(scanCallback)
+ isScanning = false
+ Log.d(TAG, "🛑 Stopped BLE scanning")
+ } catch (e: SecurityException) {
+ Log.e(TAG, "Security exception stopping scan", e)
+ } catch (e: Exception) {
+ Log.e(TAG, "Error stopping scan", e)
+ }
+ }
+
+ // ==================== CONNECTION ====================
+
+ /**
+ * Connect to a discovered device
+ */
+ fun connectToDevice(device: BluetoothDevice) {
+ if (!hasBluetoothPermissions()) {
+ Log.e(TAG, "Cannot connect - permissions not granted")
+ onError?.invoke("Bluetooth permissions not granted")
+ return
+ }
+
+ val address = device.address
+ if (connectedDevices.containsKey(address)) {
+ Log.d(TAG, "Already connected to ${device.name}")
+ return
+ }
+
+ try {
+ _connectionState.value = ConnectionState.Connecting
+ Log.d(TAG, "🔗 Connecting to ${device.name}...")
+
+ val gatt = device.connectGatt(context, false, gattCallback, BluetoothDevice.TRANSPORT_LE)
+ deviceGatts[address] = gatt
+
+ } catch (e: SecurityException) {
+ Log.e(TAG, "Security exception connecting to device", e)
+ _connectionState.value = ConnectionState.Error("Security exception: ${e.message}")
+ onError?.invoke("Security exception: ${e.message}")
+ } catch (e: Exception) {
+ Log.e(TAG, "Error connecting to device", e)
+ _connectionState.value = ConnectionState.Error("Error: ${e.message}")
+ onError?.invoke("Error connecting: ${e.message}")
+ }
+ }
+
+ /**
+ * Disconnect from a device
+ */
+ fun disconnectFromDevice(device: BluetoothDevice) {
+ val address = device.address
+ try {
+ deviceGatts[address]?.let { gatt ->
+ gatt.disconnect()
+ gatt.close()
+ }
+ deviceGatts.remove(address)
+ connectedDevices.remove(address)
+ reconnectAttempts.remove(address)
+
+ Log.d(TAG, "🔌 Disconnected from ${device.name}")
+
+ if (connectedDevices.isEmpty()) {
+ _connectionState.value = ConnectionState.Disconnected
+ }
+
+ } catch (e: SecurityException) {
+ Log.e(TAG, "Security exception disconnecting", e)
+ } catch (e: Exception) {
+ Log.e(TAG, "Error disconnecting", e)
+ }
+ }
+
+ /**
+ * Disconnect from all devices
+ */
+ fun disconnectAll() {
+ connectedDevices.values.forEach { device ->
+ disconnectFromDevice(device)
+ }
+ reconnectJob?.cancel()
+ }
+
+
+ // ==================== DATA TRANSFER ====================
+
+ /**
+ * Send data to a connected device
+ * Automatically chunks large data
+ */
+ fun sendData(device: BluetoothDevice, data: ByteArray, transferId: String = UUID.randomUUID().toString()) {
+ val address = device.address
+ val gatt = deviceGatts[address]
+
+ if (gatt == null) {
+ Log.e(TAG, "Not connected to ${device.name}")
+ onError?.invoke("Not connected to device")
+ return
+ }
+
+ try {
+ val service = gatt.getService(AIRSYNC_SERVICE_UUID)
+ val characteristic = service?.getCharacteristic(DATA_TRANSFER_CHAR_UUID)
+
+ if (characteristic == null) {
+ Log.e(TAG, "Data transfer characteristic not found")
+ onError?.invoke("Data transfer characteristic not found")
+ return
+ }
+
+ // Calculate chunks
+ val chunkSize = minOf(CHUNK_SIZE, currentMtu - 3)
+ val totalChunks = (data.size + chunkSize - 1) / chunkSize
+
+ Log.d(TAG, "📤 Sending ${data.size} bytes in $totalChunks chunks (MTU: $currentMtu)")
+
+ // Send header first
+ val header = JSONObject().apply {
+ put("type", "transfer_start")
+ put("id", transferId)
+ put("size", data.size)
+ put("chunks", totalChunks)
+ }.toString().toByteArray()
+
+ characteristic.value = header
+ characteristic.writeType = BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT
+ gatt.writeCharacteristic(characteristic)
+
+ // Send chunks with delay to prevent buffer overflow
+ CoroutineScope(Dispatchers.IO).launch {
+ for (i in 0 until totalChunks) {
+ val start = i * chunkSize
+ val end = minOf(start + chunkSize, data.size)
+ val chunk = data.copyOfRange(start, end)
+
+ // Create chunk packet with header
+ val chunkPacket = ByteArray(chunk.size + 8)
+ // Header: transferId hash (4 bytes) + chunk index (4 bytes)
+ val idHash = transferId.hashCode()
+ chunkPacket[0] = (idHash shr 24).toByte()
+ chunkPacket[1] = (idHash shr 16).toByte()
+ chunkPacket[2] = (idHash shr 8).toByte()
+ chunkPacket[3] = idHash.toByte()
+ chunkPacket[4] = (i shr 24).toByte()
+ chunkPacket[5] = (i shr 16).toByte()
+ chunkPacket[6] = (i shr 8).toByte()
+ chunkPacket[7] = i.toByte()
+ System.arraycopy(chunk, 0, chunkPacket, 8, chunk.size)
+
+ mainHandler.post {
+ try {
+ characteristic.value = chunkPacket
+ gatt.writeCharacteristic(characteristic)
+ } catch (e: Exception) {
+ Log.e(TAG, "Error writing chunk $i", e)
+ }
+ }
+
+ onTransferProgress?.invoke(transferId, i + 1, totalChunks)
+ delay(20) // Small delay between chunks
+ }
+
+ // Send completion marker
+ delay(50)
+ val footer = JSONObject().apply {
+ put("type", "transfer_end")
+ put("id", transferId)
+ }.toString().toByteArray()
+
+ mainHandler.post {
+ characteristic.value = footer
+ gatt.writeCharacteristic(characteristic)
+ }
+
+ Log.d(TAG, "✅ Transfer $transferId complete")
+ }
+
+ } catch (e: SecurityException) {
+ Log.e(TAG, "Security exception sending data", e)
+ onError?.invoke("Security exception: ${e.message}")
+ } catch (e: Exception) {
+ Log.e(TAG, "Error sending data", e)
+ onError?.invoke("Error sending data: ${e.message}")
+ }
+ }
+
+ /**
+ * Send a command to a connected device
+ */
+ fun sendCommand(device: BluetoothDevice, command: String, params: JSONObject = JSONObject()) {
+ val address = device.address
+ val gatt = deviceGatts[address]
+ val deviceName = try { device.name ?: "Unknown" } catch (e: SecurityException) { "Unknown" }
+
+ Log.d(TAG, "📤 sendCommand: $command to $deviceName ($address)")
+ Log.d(TAG, "📤 deviceGatts has entry: ${gatt != null}, connectedDevices has entry: ${connectedDevices.containsKey(address)}")
+
+ if (gatt == null) {
+ // Check if connected as server (Peripheral mode)
+ if (connectedDevices.containsKey(address)) {
+ Log.d(TAG, "📡 Sending command via Server Notification (Peripheral mode)")
+ val commandJson = JSONObject().apply {
+ put("command", command)
+ put("params", params)
+ put("timestamp", System.currentTimeMillis())
+ }
+ sendDataViaServer(device, commandJson.toString().toByteArray(StandardCharsets.UTF_8))
+ return
+ }
+
+ Log.e(TAG, "❌ Not connected to $deviceName - cannot send command")
+ return
+ }
+
+ try {
+ val service = gatt.getService(AIRSYNC_SERVICE_UUID)
+ val characteristic = service?.getCharacteristic(COMMAND_CHAR_UUID)
+
+ if (characteristic == null) {
+ Log.e(TAG, "❌ Command characteristic not found on GATT client")
+ return
+ }
+
+ val commandJson = JSONObject().apply {
+ put("command", command)
+ put("params", params)
+ put("timestamp", System.currentTimeMillis())
+ }
+
+ characteristic.value = commandJson.toString().toByteArray(StandardCharsets.UTF_8)
+ characteristic.writeType = BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT
+ gatt.writeCharacteristic(characteristic)
+
+ Log.d(TAG, "📤 Sent command via GATT Client: $command")
+
+ } catch (e: Exception) {
+ Log.e(TAG, "Error sending command", e)
+ }
+ }
+
+ /**
+ * Send data to connected device via GATT server (for server-initiated transfers)
+ */
+ fun sendDataViaServer(device: BluetoothDevice, data: ByteArray) {
+ try {
+ val deviceName = try { device.name ?: "Unknown" } catch (e: SecurityException) { "Unknown" }
+ Log.d(TAG, "📡 sendDataViaServer to $deviceName: ${data.size} bytes")
+
+ val service = gattServer?.getService(AIRSYNC_SERVICE_UUID)
+ if (service == null) {
+ Log.e(TAG, "❌ GATT Server service not found - gattServer: ${gattServer != null}")
+ return
+ }
+
+ val characteristic = service.getCharacteristic(NOTIFICATION_CHAR_UUID)
+ if (characteristic == null) {
+ Log.e(TAG, "❌ Notification characteristic not found in service")
+ return
+ }
+
+ characteristic.value = data
+ val result = gattServer?.notifyCharacteristicChanged(device, characteristic, false)
+ Log.d(TAG, "📤 notifyCharacteristicChanged result: $result, sent ${data.size} bytes")
+
+ } catch (e: SecurityException) {
+ Log.e(TAG, "Security exception sending via server", e)
+ } catch (e: Exception) {
+ Log.e(TAG, "Error sending via server", e)
+ }
+ }
+
+ // ==================== GATT SERVER SETUP ====================
+
+ private fun setupGattServer(deviceInfo: DeviceInfo?) {
+ try {
+ gattServer?.close()
+ gattServer = bluetoothManager?.openGattServer(context, gattServerCallback)
+
+ // Create AirSync service
+ val service = BluetoothGattService(
+ AIRSYNC_SERVICE_UUID,
+ BluetoothGattService.SERVICE_TYPE_PRIMARY
+ )
+
+ // Device info characteristic (read-only)
+ val deviceInfoChar = BluetoothGattCharacteristic(
+ DEVICE_INFO_CHAR_UUID,
+ BluetoothGattCharacteristic.PROPERTY_READ,
+ BluetoothGattCharacteristic.PERMISSION_READ
+ )
+
+ // Set device info value
+ val info = deviceInfo ?: DeviceInfo(
+ alias = Build.MODEL,
+ version = "2.0",
+ deviceModel = Build.MODEL,
+ deviceType = "mobile",
+ port = 6996,
+ protocol = "ws"
+ )
+ deviceInfoChar.value = JSONObject().apply {
+ put("alias", info.alias)
+ put("version", info.version)
+ put("deviceModel", info.deviceModel)
+ put("deviceType", info.deviceType)
+ put("port", info.port)
+ put("protocol", info.protocol)
+ }.toString().toByteArray(StandardCharsets.UTF_8)
+
+ // Data transfer characteristic (read/write with notifications)
+ val dataTransferChar = BluetoothGattCharacteristic(
+ DATA_TRANSFER_CHAR_UUID,
+ BluetoothGattCharacteristic.PROPERTY_READ or
+ BluetoothGattCharacteristic.PROPERTY_WRITE or
+ BluetoothGattCharacteristic.PROPERTY_WRITE_NO_RESPONSE or
+ BluetoothGattCharacteristic.PROPERTY_NOTIFY,
+ BluetoothGattCharacteristic.PERMISSION_READ or
+ BluetoothGattCharacteristic.PERMISSION_WRITE
+ )
+ dataTransferChar.addDescriptor(BluetoothGattDescriptor(
+ CCCD_UUID,
+ BluetoothGattDescriptor.PERMISSION_READ or BluetoothGattDescriptor.PERMISSION_WRITE
+ ))
+
+ // Command characteristic (write-only)
+ val commandChar = BluetoothGattCharacteristic(
+ COMMAND_CHAR_UUID,
+ BluetoothGattCharacteristic.PROPERTY_WRITE,
+ BluetoothGattCharacteristic.PERMISSION_WRITE
+ )
+
+ // Notification characteristic (notify-only for server-initiated messages)
+ val notificationChar = BluetoothGattCharacteristic(
+ NOTIFICATION_CHAR_UUID,
+ BluetoothGattCharacteristic.PROPERTY_NOTIFY or BluetoothGattCharacteristic.PROPERTY_INDICATE,
+ BluetoothGattCharacteristic.PERMISSION_READ
+ )
+ notificationChar.addDescriptor(BluetoothGattDescriptor(
+ CCCD_UUID,
+ BluetoothGattDescriptor.PERMISSION_READ or BluetoothGattDescriptor.PERMISSION_WRITE
+ ))
+
+ service.addCharacteristic(deviceInfoChar)
+ service.addCharacteristic(dataTransferChar)
+ service.addCharacteristic(commandChar)
+ service.addCharacteristic(notificationChar)
+
+ gattServer?.addService(service)
+
+ Log.d(TAG, "✅ GATT server set up with 4 characteristics")
+ } catch (e: SecurityException) {
+ Log.e(TAG, "Security exception setting up GATT server", e)
+ } catch (e: Exception) {
+ Log.e(TAG, "Error setting up GATT server", e)
+ }
+ }
+
+
+ // ==================== CALLBACKS ====================
+
+ private val advertiseCallback = object : AdvertiseCallback() {
+ override fun onStartSuccess(settingsInEffect: AdvertiseSettings?) {
+ isAdvertising = true
+ Log.d(TAG, "✅ BLE advertising started successfully")
+ }
+
+ override fun onStartFailure(errorCode: Int) {
+ isAdvertising = false
+ val errorMsg = when (errorCode) {
+ ADVERTISE_FAILED_DATA_TOO_LARGE -> "Data too large"
+ ADVERTISE_FAILED_TOO_MANY_ADVERTISERS -> "Too many advertisers"
+ ADVERTISE_FAILED_ALREADY_STARTED -> "Already started"
+ ADVERTISE_FAILED_INTERNAL_ERROR -> "Internal error"
+ ADVERTISE_FAILED_FEATURE_UNSUPPORTED -> "Feature unsupported"
+ else -> "Unknown error: $errorCode"
+ }
+ Log.e(TAG, "❌ BLE advertising failed: $errorMsg")
+ onError?.invoke("Advertising failed: $errorMsg")
+ }
+ }
+
+ private val scanCallback = object : ScanCallback() {
+ override fun onScanResult(callbackType: Int, result: ScanResult?) {
+ result?.let { scanResult ->
+ try {
+ val device = scanResult.device
+ val rssi = scanResult.rssi
+
+ // Get device name - try multiple sources
+ val name = try {
+ device.name?.takeIf { it.isNotBlank() }
+ ?: scanResult.scanRecord?.deviceName?.takeIf { it.isNotBlank() }
+ ?: ""
+ } catch (e: SecurityException) {
+ ""
+ }
+
+ // Check if this is an AirSync device by service UUID
+ val serviceUuids = scanResult.scanRecord?.serviceUuids ?: emptyList()
+ val isAirSyncDevice = serviceUuids.any { it.uuid == AIRSYNC_SERVICE_UUID }
+
+ // ONLY show AirSync devices - skip everything else
+ if (!isAirSyncDevice) {
+ return@let
+ }
+
+ Log.d(TAG, "🔍 Found AirSync device: $name (RSSI: $rssi)")
+
+ // Parse device info from scan record if available
+ var deviceInfo: DeviceInfo? = null
+ scanResult.scanRecord?.serviceData?.get(ParcelUuid(AIRSYNC_SERVICE_UUID))?.let { data ->
+ try {
+ val json = JSONObject(String(data, StandardCharsets.UTF_8))
+ deviceInfo = DeviceInfo(
+ alias = json.optString("alias", name),
+ version = json.optString("version", "2.0"),
+ deviceModel = json.optString("deviceModel"),
+ deviceType = json.optString("deviceType", "desktop"),
+ port = json.optInt("port", 6996),
+ protocol = json.optString("protocol", "ws")
+ )
+ } catch (e: Exception) {
+ Log.w(TAG, "Failed to parse device info from scan data")
+ }
+ }
+
+ // Create device info if not parsed from service data
+ if (deviceInfo == null) {
+ deviceInfo = DeviceInfo(
+ alias = name.ifEmpty { "AirSync Mac" },
+ version = "2.0",
+ deviceModel = name.ifEmpty { "Mac" },
+ deviceType = "desktop",
+ port = 6996,
+ protocol = "ws"
+ )
+ }
+
+ val discovered = DiscoveredDevice(
+ device = device,
+ name = name.ifEmpty { "AirSync Mac" },
+ rssi = rssi,
+ deviceInfo = deviceInfo
+ )
+
+ // Update discovered devices list
+ val currentList = _discoveredDevices.value.toMutableList()
+ val existingIndex = currentList.indexOfFirst { it.device.address == device.address }
+ if (existingIndex >= 0) {
+ currentList[existingIndex] = discovered
+ } else {
+ currentList.add(discovered)
+ Log.d(TAG, "✅ Added AirSync device: ${discovered.name}")
+ }
+
+ // Sort by signal strength
+ currentList.sortByDescending { it.rssi }
+
+ _discoveredDevices.value = currentList
+ onDeviceDiscovered?.invoke(discovered)
+
+ } catch (e: SecurityException) {
+ Log.e(TAG, "Security exception in scan result", e)
+ } catch (e: Exception) {
+ Log.e(TAG, "Error processing scan result", e)
+ }
+ }
+ }
+
+ override fun onBatchScanResults(results: MutableList?) {
+ results?.forEach { result ->
+ onScanResult(ScanSettings.CALLBACK_TYPE_ALL_MATCHES, result)
+ }
+ }
+
+ override fun onScanFailed(errorCode: Int) {
+ isScanning = false
+ val errorMsg = when (errorCode) {
+ SCAN_FAILED_ALREADY_STARTED -> "Already started"
+ SCAN_FAILED_APPLICATION_REGISTRATION_FAILED -> "App registration failed"
+ SCAN_FAILED_INTERNAL_ERROR -> "Internal error"
+ SCAN_FAILED_FEATURE_UNSUPPORTED -> "Feature unsupported"
+ else -> "Unknown error: $errorCode"
+ }
+ Log.e(TAG, "❌ BLE scan failed: $errorMsg")
+ onError?.invoke("Scan failed: $errorMsg")
+ }
+ }
+
+ private val gattCallback = object : BluetoothGattCallback() {
+ override fun onConnectionStateChange(gatt: BluetoothGatt?, status: Int, newState: Int) {
+ try {
+ val device = gatt?.device ?: return
+ val address = device.address
+
+ when (newState) {
+ BluetoothProfile.STATE_CONNECTED -> {
+ Log.d(TAG, "✅ Connected to ${device.name}")
+ connectedDevices[address] = device
+ reconnectAttempts.remove(address)
+ _connectionState.value = ConnectionState.Connected
+
+ // Remove from discovered list
+ val currentList = _discoveredDevices.value.toMutableList()
+ currentList.removeAll { it.device.address == address }
+ _discoveredDevices.value = currentList
+
+ // Request higher MTU for better throughput
+ gatt.requestMtu(MAX_MTU)
+
+ mainHandler.post {
+ onDeviceConnected?.invoke(device)
+ }
+ }
+ BluetoothProfile.STATE_DISCONNECTED -> {
+ Log.d(TAG, "🔌 Disconnected from ${device.name}")
+ connectedDevices.remove(address)
+
+ mainHandler.post {
+ onDeviceDisconnected?.invoke(device)
+ }
+
+ // Attempt reconnect if not intentional
+ val attempts = reconnectAttempts.getOrDefault(address, 0)
+ if (attempts < MAX_RECONNECT_ATTEMPTS) {
+ reconnectAttempts[address] = attempts + 1
+ scheduleReconnect(device)
+ } else {
+ deviceGatts.remove(address)?.close()
+ reconnectAttempts.remove(address)
+ if (connectedDevices.isEmpty()) {
+ _connectionState.value = ConnectionState.Disconnected
+ }
+ }
+ }
+ }
+ } catch (e: SecurityException) {
+ Log.e(TAG, "Security exception in connection state change", e)
+ }
+ }
+
+ override fun onMtuChanged(gatt: BluetoothGatt?, mtu: Int, status: Int) {
+ if (status == BluetoothGatt.GATT_SUCCESS) {
+ currentMtu = mtu
+ Log.d(TAG, "📏 MTU changed to $mtu")
+ }
+
+ // Discover services after MTU negotiation
+ try {
+ gatt?.discoverServices()
+ } catch (e: SecurityException) {
+ Log.e(TAG, "Security exception discovering services", e)
+ }
+ }
+
+ override fun onServicesDiscovered(gatt: BluetoothGatt?, status: Int) {
+ if (status == BluetoothGatt.GATT_SUCCESS) {
+ Log.d(TAG, "✅ Services discovered")
+
+ // Enable notifications on data transfer characteristic
+ try {
+ val service = gatt?.getService(AIRSYNC_SERVICE_UUID)
+ val dataChar = service?.getCharacteristic(DATA_TRANSFER_CHAR_UUID)
+ val commandChar = service?.getCharacteristic(COMMAND_CHAR_UUID)
+
+ Log.d(TAG, "📋 Service found: ${service != null}")
+ Log.d(TAG, "📋 Data char found: ${dataChar != null}")
+ Log.d(TAG, "📋 Command char found: ${commandChar != null}")
+
+ if (dataChar != null) {
+ gatt.setCharacteristicNotification(dataChar, true)
+ val descriptor = dataChar.getDescriptor(CCCD_UUID)
+ descriptor?.value = BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE
+ gatt.writeDescriptor(descriptor)
+ }
+
+ // Notify that services are ready - this is when we can send commands
+ gatt?.device?.let { device ->
+ mainHandler.post {
+ onServicesDiscovered?.invoke(device)
+ }
+ }
+ } catch (e: SecurityException) {
+ Log.e(TAG, "Security exception enabling notifications", e)
+ }
+ } else {
+ Log.e(TAG, "❌ Service discovery failed: $status")
+ }
+ }
+
+ override fun onCharacteristicRead(
+ gatt: BluetoothGatt?,
+ characteristic: BluetoothGattCharacteristic?,
+ status: Int
+ ) {
+ if (status == BluetoothGatt.GATT_SUCCESS && characteristic != null) {
+ val data = characteristic.value
+ Log.d(TAG, "📥 Read ${data?.size ?: 0} bytes from ${characteristic.uuid}")
+ }
+ }
+
+ override fun onCharacteristicChanged(
+ gatt: BluetoothGatt?,
+ characteristic: BluetoothGattCharacteristic?
+ ) {
+ characteristic?.value?.let { data ->
+ handleReceivedData(gatt?.device?.address ?: "", data)
+ }
+ }
+
+ override fun onCharacteristicWrite(
+ gatt: BluetoothGatt?,
+ characteristic: BluetoothGattCharacteristic?,
+ status: Int
+ ) {
+ if (status != BluetoothGatt.GATT_SUCCESS) {
+ Log.e(TAG, "❌ Write failed: $status")
+ }
+ }
+
+ override fun onDescriptorWrite(gatt: BluetoothGatt?, descriptor: BluetoothGattDescriptor?, status: Int) {
+ if (status == BluetoothGatt.GATT_SUCCESS) {
+ Log.d(TAG, "✅ Descriptor written for ${descriptor?.characteristic?.uuid}")
+
+ // Chain subscriptions: Data -> Command
+ if (descriptor?.characteristic?.uuid == DATA_TRANSFER_CHAR_UUID) {
+ try {
+ val service = gatt?.getService(AIRSYNC_SERVICE_UUID)
+ val commandChar = service?.getCharacteristic(COMMAND_CHAR_UUID)
+
+ if (commandChar != null) {
+ gatt.setCharacteristicNotification(commandChar, true)
+ val desc = commandChar.getDescriptor(CCCD_UUID)
+ if (desc != null) {
+ desc.value = BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE
+ gatt.writeDescriptor(desc)
+ Log.d(TAG, "🔔 Subscribing to Command Char...")
+ }
+ }
+ } catch (e: SecurityException) {
+ Log.e(TAG, "Security exception subscribing to command char", e)
+ }
+ }
+ } else {
+ Log.e(TAG, "❌ Descriptor write failed: $status")
+ }
+ }
+ }
+
+ private val gattServerCallback = object : BluetoothGattServerCallback() {
+ override fun onConnectionStateChange(device: BluetoothDevice?, status: Int, newState: Int) {
+ try {
+ when (newState) {
+ BluetoothProfile.STATE_CONNECTED -> {
+ Log.d(TAG, "✅ Client connected: ${device?.name}")
+ device?.let {
+ connectedDevices[it.address] = it
+ _connectionState.value = ConnectionState.Connected
+ mainHandler.post { onDeviceConnected?.invoke(it) }
+ }
+ }
+ BluetoothProfile.STATE_DISCONNECTED -> {
+ Log.d(TAG, "🔌 Client disconnected: ${device?.name}")
+ device?.let {
+ connectedDevices.remove(it.address)
+ mainHandler.post { onDeviceDisconnected?.invoke(it) }
+ }
+ // Update connection state if no more connected devices
+ if (connectedDevices.isEmpty()) {
+ _connectionState.value = ConnectionState.Disconnected
+ }
+ }
+ }
+ } catch (e: SecurityException) {
+ Log.e(TAG, "Security exception in server connection state", e)
+ }
+ }
+
+ override fun onCharacteristicReadRequest(
+ device: BluetoothDevice?,
+ requestId: Int,
+ offset: Int,
+ characteristic: BluetoothGattCharacteristic?
+ ) {
+ try {
+ val value = characteristic?.value ?: ByteArray(0)
+ val response = if (offset < value.size) {
+ value.copyOfRange(offset, value.size)
+ } else {
+ ByteArray(0)
+ }
+ gattServer?.sendResponse(device, requestId, BluetoothGatt.GATT_SUCCESS, offset, response)
+ } catch (e: SecurityException) {
+ Log.e(TAG, "Security exception in read request", e)
+ }
+ }
+
+ override fun onCharacteristicWriteRequest(
+ device: BluetoothDevice?,
+ requestId: Int,
+ characteristic: BluetoothGattCharacteristic?,
+ preparedWrite: Boolean,
+ responseNeeded: Boolean,
+ offset: Int,
+ value: ByteArray?
+ ) {
+ try {
+ value?.let { data ->
+ when (characteristic?.uuid) {
+ DATA_TRANSFER_CHAR_UUID -> handleReceivedData(device?.address ?: "", data)
+ COMMAND_CHAR_UUID -> handleReceivedCommand(device?.address ?: "", data)
+ }
+ }
+
+ if (responseNeeded) {
+ gattServer?.sendResponse(device, requestId, BluetoothGatt.GATT_SUCCESS, offset, value)
+ }
+ } catch (e: SecurityException) {
+ Log.e(TAG, "Security exception in write request", e)
+ }
+ }
+
+ override fun onDescriptorWriteRequest(
+ device: BluetoothDevice?,
+ requestId: Int,
+ descriptor: BluetoothGattDescriptor?,
+ preparedWrite: Boolean,
+ responseNeeded: Boolean,
+ offset: Int,
+ value: ByteArray?
+ ) {
+ try {
+ if (responseNeeded) {
+ gattServer?.sendResponse(device, requestId, BluetoothGatt.GATT_SUCCESS, offset, value)
+ }
+ } catch (e: SecurityException) {
+ Log.e(TAG, "Security exception in descriptor write", e)
+ }
+ }
+
+ override fun onMtuChanged(device: BluetoothDevice?, mtu: Int) {
+ currentMtu = mtu
+ Log.d(TAG, "📏 Server MTU changed to $mtu")
+ }
+ }
+
+ // ==================== HELPERS ====================
+
+ private fun handleReceivedData(address: String, data: ByteArray) {
+ try {
+ // Check if it's a JSON control message
+ val str = String(data, StandardCharsets.UTF_8)
+ if (str.startsWith("{")) {
+ val json = JSONObject(str)
+ when (json.optString("type")) {
+ "transfer_start" -> {
+ val id = json.getString("id")
+ val size = json.getInt("size")
+ val chunks = json.getInt("chunks")
+ pendingTransfers[id] = DataTransfer(id, size, chunks)
+ receivedChunks[id] = mutableListOf()
+ Log.d(TAG, "📥 Starting transfer $id: $size bytes in $chunks chunks")
+ }
+ "transfer_end" -> {
+ val id = json.getString("id")
+ val transfer = pendingTransfers.remove(id)
+ val chunks = receivedChunks.remove(id)
+
+ if (transfer != null && chunks != null) {
+ // Reassemble data
+ val totalSize = chunks.sumOf { it.size }
+ val result = ByteArray(totalSize)
+ var offset = 0
+ chunks.forEach { chunk ->
+ System.arraycopy(chunk, 0, result, offset, chunk.size)
+ offset += chunk.size
+ }
+ Log.d(TAG, "✅ Transfer $id complete: ${result.size} bytes")
+ mainHandler.post { onTransferComplete?.invoke(id, result) }
+ }
+ }
+ else -> {
+ mainHandler.post { onDataReceived?.invoke(address, data) }
+ }
+ }
+ } else if (data.size > 8) {
+ // Chunk data: first 8 bytes are header
+ val idHash = ((data[0].toInt() and 0xFF) shl 24) or
+ ((data[1].toInt() and 0xFF) shl 16) or
+ ((data[2].toInt() and 0xFF) shl 8) or
+ (data[3].toInt() and 0xFF)
+ val chunkIndex = ((data[4].toInt() and 0xFF) shl 24) or
+ ((data[5].toInt() and 0xFF) shl 16) or
+ ((data[6].toInt() and 0xFF) shl 8) or
+ (data[7].toInt() and 0xFF)
+
+ val chunkData = data.copyOfRange(8, data.size)
+
+ // Find matching transfer by hash
+ pendingTransfers.entries.find { it.key.hashCode() == idHash }?.let { entry ->
+ receivedChunks[entry.key]?.add(chunkData)
+ entry.value.receivedChunks++
+ mainHandler.post {
+ onTransferProgress?.invoke(entry.key, entry.value.receivedChunks, entry.value.totalChunks)
+ }
+ }
+ } else {
+ mainHandler.post { onDataReceived?.invoke(address, data) }
+ }
+ } catch (e: Exception) {
+ Log.e(TAG, "Error handling received data", e)
+ mainHandler.post { onDataReceived?.invoke(address, data) }
+ }
+ }
+
+ private fun handleReceivedCommand(address: String, data: ByteArray) {
+ try {
+ val str = String(data, StandardCharsets.UTF_8)
+ Log.d(TAG, "📥 Received command data: $str")
+
+ val json = JSONObject(str)
+
+ // Handle command format: { "command": "...", "params": {...} }
+ val command = json.optString("command")
+ if (command.isNotEmpty()) {
+ val params = json.optJSONObject("params") ?: JSONObject()
+ Log.d(TAG, "📥 Parsed command: $command")
+ mainHandler.post { onCommandReceived?.invoke(command, params) }
+ return
+ }
+
+ // Handle legacy format: { "type": "...", "data": {...} }
+ val type = json.optString("type")
+ if (type.isNotEmpty()) {
+ val params = json.optJSONObject("data") ?: JSONObject()
+ Log.d(TAG, "📥 Parsed legacy command type: $type")
+ mainHandler.post { onCommandReceived?.invoke(type, params) }
+ return
+ }
+
+ Log.w(TAG, "Unknown command format: $str")
+ } catch (e: Exception) {
+ Log.e(TAG, "Error parsing command: ${e.message}")
+ }
+ }
+
+ private fun scheduleReconnect(device: BluetoothDevice) {
+ reconnectJob?.cancel()
+ reconnectJob = CoroutineScope(Dispatchers.IO).launch {
+ delay(RECONNECT_DELAY_MS)
+ Log.d(TAG, "🔄 Attempting reconnect to ${device.name}...")
+ mainHandler.post { connectToDevice(device) }
+ }
+ }
+
+ /**
+ * Clean up all resources
+ */
+ fun cleanup() {
+ stopScanning()
+ stopAdvertising()
+ disconnectAll()
+ gattServer?.close()
+ gattServer = null
+ bluetoothLeAdvertiser = null
+ bluetoothLeScanner = null
+ bluetoothAdapter = null
+ bluetoothManager = null
+ pendingTransfers.clear()
+ receivedChunks.clear()
+ reconnectJob?.cancel()
+ Log.d(TAG, "🧹 Bluetooth helper cleaned up")
+ }
+
+ /**
+ * Check if Bluetooth is enabled
+ */
+ fun isBluetoothEnabled(): Boolean = bluetoothAdapter?.isEnabled == true
+
+ /**
+ * Check if currently advertising
+ */
+ fun isAdvertising(): Boolean = isAdvertising
+
+ /**
+ * Check if currently scanning
+ */
+ fun isScanning(): Boolean = isScanning
+
+ /**
+ * Get list of connected devices
+ */
+ fun getConnectedDevices(): List = connectedDevices.values.toList()
+}
diff --git a/app/src/main/java/com/sameerasw/airsync/utils/BluetoothSyncManager.kt b/app/src/main/java/com/sameerasw/airsync/utils/BluetoothSyncManager.kt
new file mode 100644
index 0000000..28436db
--- /dev/null
+++ b/app/src/main/java/com/sameerasw/airsync/utils/BluetoothSyncManager.kt
@@ -0,0 +1,542 @@
+package com.sameerasw.airsync.utils
+
+import android.bluetooth.BluetoothDevice
+import android.content.Context
+import android.content.SharedPreferences
+import android.util.Log
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.launch
+import org.json.JSONObject
+import kotlin.random.Random
+
+/**
+ * Manages Bluetooth data synchronization as an alternative/supplement to WebSocket
+ *
+ * Features:
+ * - 3-option pairing code verification
+ * - Bidirectional pairing initiation
+ * - Persistent paired device storage
+ * - Auto-connect on app launch
+ */
+object BluetoothSyncManager {
+ private const val TAG = "BluetoothSyncManager"
+ private const val PREFS_NAME = "bluetooth_pairing_prefs"
+ private const val KEY_PAIRED_DEVICE_ADDRESS = "paired_device_address"
+ private const val KEY_PAIRED_DEVICE_NAME = "paired_device_name"
+ private const val KEY_AUTO_CONNECT_ENABLED = "auto_connect_enabled"
+
+ private var bluetoothHelper: BluetoothHelper? = null
+ private var isInitialized = false
+ private var prefs: SharedPreferences? = null
+
+ // Connection state
+ private val _isConnected = MutableStateFlow(false)
+ val isConnected: StateFlow = _isConnected
+
+ private val _connectedDeviceName = MutableStateFlow(null)
+ val connectedDeviceName: StateFlow = _connectedDeviceName
+
+ private val _connectionState = MutableStateFlow(ConnectionState.Disconnected)
+ val connectionState: StateFlow = _connectionState
+
+ // Pairing state
+ private val _pairingState = MutableStateFlow(PairingState.Idle)
+ val pairingState: StateFlow = _pairingState
+
+ private var pendingPairingDevice: BluetoothDevice? = null
+
+ // Paired device info
+ private val _pairedDevice = MutableStateFlow(null)
+ val pairedDevice: StateFlow = _pairedDevice
+
+ sealed class ConnectionState {
+ object Disconnected : ConnectionState()
+ object Scanning : ConnectionState()
+ object Connecting : ConnectionState()
+ data class Connected(val deviceName: String) : ConnectionState()
+ data class Error(val message: String) : ConnectionState()
+ }
+
+ sealed class PairingState {
+ object Idle : PairingState()
+ data class ConfirmationRequired(val code: String) : PairingState() // We received a request, user needs to confirm
+ data class WaitingForConfirmation(val code: String) : PairingState() // We sent a request, waiting for peer to confirm
+ object Success : PairingState()
+ data class Failed(val reason: String) : PairingState()
+ }
+
+ data class PairedDeviceInfo(
+ val address: String,
+ val name: String,
+ val pairedAt: Long = System.currentTimeMillis()
+ )
+
+ // Pairing timeout job
+ private var pairingTimeoutJob: kotlinx.coroutines.Job? = null
+ private const val PAIRING_TIMEOUT_MS = 30000L // 30 seconds
+
+ // Track if we're waiting for services to be discovered before sending pairing request
+ private var pendingPairingRequestDevice: BluetoothDevice? = null
+ private var pendingPairingCode: String? = null
+
+ var onMessageReceived: ((String) -> Unit)? = null
+ var onConnectionStateChanged: ((Boolean, String?) -> Unit)? = null
+
+ fun initialize(context: Context) {
+ if (isInitialized) return
+
+ prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
+ loadPairedDevice()
+
+ bluetoothHelper = BluetoothHelper(context).apply {
+ if (!initialize()) {
+ Log.e(TAG, "Failed to initialize Bluetooth")
+ return@apply
+ }
+
+ onDeviceConnected = { device ->
+ val deviceName = try { device.name ?: "Unknown Device" } catch (e: SecurityException) { "Unknown Device" }
+ Log.d(TAG, "✅ Bluetooth connected to: $deviceName")
+ _isConnected.value = true
+ _connectedDeviceName.value = deviceName
+ _connectionState.value = ConnectionState.Connected(deviceName)
+ notifyConnectionChange(true, deviceName)
+ }
+
+ onDeviceDisconnected = { device ->
+ val deviceName = try { device?.name } catch (e: SecurityException) { null }
+ val deviceAddress = try { device?.address } catch (e: SecurityException) { null }
+ Log.d(TAG, "🔌 Bluetooth disconnected from: $deviceName")
+
+ // Only update UI state if the PAIRED device disconnected
+ val pairedAddress = _pairedDevice.value?.address
+ if (pairedAddress != null && deviceAddress == pairedAddress) {
+ Log.d(TAG, "🔌 Paired device disconnected - updating UI state")
+ _isConnected.value = false
+ _connectedDeviceName.value = null
+ _connectionState.value = ConnectionState.Disconnected
+ pendingPairingRequestDevice = null
+ notifyConnectionChange(false, null)
+ } else {
+ Log.d(TAG, "🔌 Non-paired device disconnected - ignoring for UI")
+ }
+ }
+
+ // This is called when GATT services are discovered and ready
+ onServicesDiscovered = { device ->
+ Log.d(TAG, "📋 Services discovered for: ${device.name}")
+ // If we have a pending pairing request, send it now
+ if (pendingPairingRequestDevice?.address == device.address) {
+ val code = pendingPairingCode
+ if (code != null) {
+ Log.d(TAG, "📤 Sending pending pairing request now that services are ready")
+ sendPairingRequest(code)
+ }
+ pendingPairingRequestDevice = null
+ pendingPairingCode = null
+ }
+ }
+
+ onDataReceived = { _, data -> handleReceivedData(data) }
+ onCommandReceived = { command, params -> handleReceivedCommand(command, params) }
+ onError = { error ->
+ Log.e(TAG, "Bluetooth error: $error")
+ _connectionState.value = ConnectionState.Error(error)
+ }
+ }
+
+ isInitialized = true
+ Log.d(TAG, "✅ BluetoothSyncManager initialized")
+ }
+
+ // Advertising state
+ private val _isAdvertising = MutableStateFlow(false)
+ val isAdvertising: StateFlow = _isAdvertising
+
+ fun startAdvertising() {
+ bluetoothHelper?.startAdvertising()
+ _isAdvertising.value = true
+ }
+
+ fun stopAdvertising() {
+ bluetoothHelper?.stopAdvertising()
+ _isAdvertising.value = false
+ }
+
+ fun startScanning() {
+ _connectionState.value = ConnectionState.Scanning
+ bluetoothHelper?.startScanning()
+ }
+
+ fun stopScanning() {
+ if (_connectionState.value is ConnectionState.Scanning) {
+ _connectionState.value = ConnectionState.Disconnected
+ }
+ bluetoothHelper?.stopScanning()
+ }
+
+ /**
+ * Refresh the connection state by checking if the paired device is connected.
+ * Only considers the paired device, not other Bluetooth devices like smartwatches.
+ */
+ fun refreshConnectionState() {
+ val pairedAddress = _pairedDevice.value?.address ?: return
+ val connectedDevices = bluetoothHelper?.getConnectedDevices() ?: emptyList()
+
+ // Only check if the PAIRED device is connected, ignore other devices
+ val pairedDeviceConnected = connectedDevices.any {
+ try { it.address == pairedAddress } catch (e: SecurityException) { false }
+ }
+
+ Log.d(TAG, "🔄 Refreshing connection state: paired=$pairedAddress, connected=$pairedDeviceConnected")
+
+ if (pairedDeviceConnected) {
+ val deviceName = _pairedDevice.value?.name ?: "Unknown Device"
+ _isConnected.value = true
+ _connectedDeviceName.value = deviceName
+ _connectionState.value = ConnectionState.Connected(deviceName)
+ } else {
+ _isConnected.value = false
+ _connectedDeviceName.value = null
+ if (_connectionState.value is ConnectionState.Connected) {
+ _connectionState.value = ConnectionState.Disconnected
+ }
+ }
+ }
+
+ fun initiatePairing(device: BluetoothDevice) {
+ pendingPairingDevice = device
+
+ // Generate a 6-digit code
+ val code = String.format("%06d", Random.nextInt(1000000))
+
+ _pairingState.value = PairingState.WaitingForConfirmation(code)
+ _connectionState.value = ConnectionState.Connecting
+
+ Log.d(TAG, "🔐 Pairing initiated - generated code: $code")
+
+ // Start pairing timeout
+ startPairingTimeout()
+
+ // Check if already connected
+ val connectedDevices = bluetoothHelper?.getConnectedDevices() ?: emptyList()
+ if (connectedDevices.any { it.address == device.address }) {
+ Log.d(TAG, "🔐 Device already connected, sending pairing request immediately")
+ sendPairingRequest(code)
+ } else {
+ // Mark this device as pending pairing request - will be sent when services are discovered
+ pendingPairingRequestDevice = device
+ pendingPairingCode = code
+
+ bluetoothHelper?.connectToDevice(device)
+ Log.d(TAG, "🔐 Connecting to device to send pairing request...")
+ }
+ }
+
+ fun acceptPairing() {
+ Log.d(TAG, "✅ User accepted pairing")
+ sendPairingAccepted()
+ pendingPairingDevice?.let { completePairing(it) }
+ }
+
+ fun rejectPairing() {
+ Log.d(TAG, "❌ User rejected pairing")
+ _pairingState.value = PairingState.Idle
+ cancelPairing()
+ }
+
+ private fun handlePairingRequest(code: String) {
+ Log.d(TAG, "📥 Received pairing request with code: $code")
+
+ // We need to know which device sent this, but for now we assume it's the connected one
+ // In a real scenario we'd map the device address
+ val connectedDevices = bluetoothHelper?.getConnectedDevices() ?: emptyList()
+ if (connectedDevices.isNotEmpty()) {
+ pendingPairingDevice = connectedDevices.first()
+ }
+
+ _pairingState.value = PairingState.ConfirmationRequired(code)
+ startPairingTimeout()
+ }
+
+ private fun startPairingTimeout() {
+ pairingTimeoutJob?.cancel()
+ pairingTimeoutJob = CoroutineScope(Dispatchers.Main).launch {
+ kotlinx.coroutines.delay(PAIRING_TIMEOUT_MS)
+ if (_pairingState.value !is PairingState.Success && _pairingState.value !is PairingState.Idle) {
+ Log.d(TAG, "⏱️ Pairing timeout")
+ _pairingState.value = PairingState.Failed("Pairing timed out")
+ cancelPairing()
+ }
+ }
+ }
+
+ private fun completePairing(device: BluetoothDevice) {
+ pairingTimeoutJob?.cancel()
+ pairingTimeoutJob = null
+
+ val deviceName = try { device.name ?: "Unknown Device" } catch (e: SecurityException) { "Unknown Device" }
+ val pairedInfo = PairedDeviceInfo(address = device.address, name = deviceName)
+
+ _pairedDevice.value = pairedInfo
+ savePairedDevice(pairedInfo)
+
+ _pairingState.value = PairingState.Success
+ _connectionState.value = ConnectionState.Connected(deviceName)
+ _isConnected.value = true
+ _connectedDeviceName.value = deviceName
+
+ Log.d(TAG, "✅ Pairing completed and saved: $deviceName")
+
+ CoroutineScope(Dispatchers.Main).launch {
+ kotlinx.coroutines.delay(2000)
+ _pairingState.value = PairingState.Idle
+ }
+ }
+
+ fun cancelPairing() {
+ pairingTimeoutJob?.cancel()
+ pairingTimeoutJob = null
+ pendingPairingDevice = null
+ pendingPairingRequestDevice = null
+ pendingPairingCode = null
+ _pairingState.value = PairingState.Idle
+ _connectionState.value = ConnectionState.Disconnected
+ bluetoothHelper?.disconnectAll()
+ }
+
+ fun connect(device: BluetoothDevice) {
+ _connectionState.value = ConnectionState.Connecting
+ bluetoothHelper?.connectToDevice(device)
+ }
+
+ fun tryAutoConnect() {
+ val paired = _pairedDevice.value ?: return
+ if (!isAutoConnectEnabled()) return
+
+ Log.d(TAG, "🔄 Attempting auto-connect to ${paired.name}")
+ _connectionState.value = ConnectionState.Scanning
+ bluetoothHelper?.startScanning()
+
+ CoroutineScope(Dispatchers.IO).launch {
+ var attempts = 0
+ while (attempts < 10 && _connectionState.value is ConnectionState.Scanning) {
+ kotlinx.coroutines.delay(1000)
+ val discovered = bluetoothHelper?.discoveredDevices?.value ?: emptyList()
+ val found = discovered.find { it.device.address == paired.address }
+ if (found != null) {
+ Log.d(TAG, "✅ Found paired device, connecting...")
+ _connectionState.value = ConnectionState.Connecting
+ bluetoothHelper?.connectToDevice(found.device)
+ return@launch
+ }
+ attempts++
+ }
+ if (_connectionState.value is ConnectionState.Scanning) {
+ Log.d(TAG, "⏱️ Auto-connect timeout")
+ _connectionState.value = ConnectionState.Disconnected
+ bluetoothHelper?.stopScanning()
+ }
+ }
+ }
+
+ fun disconnect() {
+ bluetoothHelper?.disconnectAll()
+ _isConnected.value = false
+ _connectedDeviceName.value = null
+ _connectionState.value = ConnectionState.Disconnected
+ }
+
+ fun forgetPairedDevice() {
+ disconnect()
+ _pairedDevice.value = null
+ prefs?.edit()?.apply {
+ remove(KEY_PAIRED_DEVICE_ADDRESS)
+ remove(KEY_PAIRED_DEVICE_NAME)
+ apply()
+ }
+ Log.d(TAG, "🗑️ Paired device forgotten")
+ }
+
+ fun setAutoConnectEnabled(enabled: Boolean) {
+ prefs?.edit()?.putBoolean(KEY_AUTO_CONNECT_ENABLED, enabled)?.apply()
+ }
+
+ fun isAutoConnectEnabled(): Boolean = prefs?.getBoolean(KEY_AUTO_CONNECT_ENABLED, true) ?: true
+
+ fun sendMessage(message: String): Boolean {
+ if (!_isConnected.value) return false
+ val connectedDevices = bluetoothHelper?.getConnectedDevices() ?: emptyList()
+ if (connectedDevices.isEmpty()) return false
+
+ return try {
+ val data = message.toByteArray(Charsets.UTF_8)
+ connectedDevices.forEach { bluetoothHelper?.sendData(it, data) }
+ true
+ } catch (e: Exception) {
+ Log.e(TAG, "Error sending via Bluetooth: ${e.message}")
+ false
+ }
+ }
+
+ fun sendCommand(command: String, params: JSONObject = JSONObject()): Boolean {
+ if (!_isConnected.value) return false
+ val connectedDevices = bluetoothHelper?.getConnectedDevices() ?: emptyList()
+ if (connectedDevices.isEmpty()) return false
+
+ return try {
+ connectedDevices.forEach { bluetoothHelper?.sendCommand(it, command, params) }
+ true
+ } catch (e: Exception) {
+ Log.e(TAG, "Error sending command: ${e.message}")
+ false
+ }
+ }
+
+ fun isAvailable(): Boolean = _isConnected.value
+ fun getHelper(): BluetoothHelper? = bluetoothHelper
+
+ private fun handleReceivedData(data: ByteArray) {
+ try {
+ val message = String(data, Charsets.UTF_8)
+ if (message.contains("\"type\":\"pairing") || message.contains("\"command\":\"pairing")) {
+ handlePairingMessage(message)
+ return
+ }
+ onMessageReceived?.invoke(message)
+ } catch (e: Exception) {
+ Log.e(TAG, "Error handling received data: ${e.message}")
+ }
+ }
+
+ private fun handleReceivedCommand(command: String, params: JSONObject) {
+ Log.d(TAG, "📥 Received command: $command with params: $params")
+ when (command) {
+ "pairingRequest" -> {
+ val code = params.optString("code")
+ if (code.isNotEmpty()) {
+ handlePairingRequest(code)
+ }
+ }
+ "pairingAccepted" -> {
+ Log.d(TAG, "✅ Peer accepted pairing!")
+ pendingPairingDevice?.let { completePairing(it) }
+ }
+ else -> {
+ val message = JSONObject().apply {
+ put("type", command)
+ put("data", params)
+ }.toString()
+ onMessageReceived?.invoke(message)
+ }
+ }
+ }
+
+ private fun handlePairingMessage(message: String) {
+ try {
+ val json = JSONObject(message)
+ val type = json.optString("type")
+ val command = json.optString("command")
+ val data = json.optJSONObject("data") ?: json.optJSONObject("params") ?: JSONObject()
+
+ when {
+ type == "pairingRequest" || command == "pairingRequest" -> {
+ val code = data.optString("code")
+ if (code.isNotEmpty()) {
+ handlePairingRequest(code)
+ }
+ }
+ type == "pairingAccepted" || command == "pairingAccepted" -> {
+ Log.d(TAG, "✅ Peer accepted pairing!")
+ pendingPairingDevice?.let { completePairing(it) }
+ }
+ }
+ } catch (e: Exception) {
+ Log.e(TAG, "Error parsing pairing message: ${e.message}")
+ }
+ }
+
+ private fun sendPairingRequest(code: String) {
+ val params = JSONObject().apply {
+ put("code", code)
+ }
+
+ val connectedDevices = bluetoothHelper?.getConnectedDevices() ?: emptyList()
+ connectedDevices.forEach { device ->
+ bluetoothHelper?.sendCommand(device, "pairingRequest", params)
+ }
+ Log.d(TAG, "📤 Sent pairing request with code: $code")
+ }
+
+ private fun sendPairingAccepted() {
+ val connectedDevices = bluetoothHelper?.getConnectedDevices() ?: emptyList()
+ Log.d(TAG, "📤 Sending pairing accepted to ${connectedDevices.size} connected devices")
+
+ if (connectedDevices.isEmpty()) {
+ Log.w(TAG, "⚠️ No connected devices found to send pairingAccepted!")
+ return
+ }
+
+ connectedDevices.forEach { device ->
+ try {
+ val deviceName = device.name ?: "Unknown"
+ Log.d(TAG, "📤 Sending pairingAccepted to: $deviceName (${device.address})")
+ bluetoothHelper?.sendCommand(device, "pairingAccepted")
+ } catch (e: SecurityException) {
+ Log.e(TAG, "Security exception sending pairingAccepted", e)
+ }
+ }
+ Log.d(TAG, "📤 Sent pairing accepted")
+ }
+
+ private fun savePairedDevice(device: PairedDeviceInfo) {
+ prefs?.edit()?.apply {
+ putString(KEY_PAIRED_DEVICE_ADDRESS, device.address)
+ putString(KEY_PAIRED_DEVICE_NAME, device.name)
+ apply()
+ }
+ }
+
+ private fun loadPairedDevice() {
+ val address = prefs?.getString(KEY_PAIRED_DEVICE_ADDRESS, null)
+ val name = prefs?.getString(KEY_PAIRED_DEVICE_NAME, null)
+ if (address != null && name != null) {
+ _pairedDevice.value = PairedDeviceInfo(address, name)
+ Log.d(TAG, "📱 Loaded paired device: $name ($address)")
+ }
+ }
+
+ private fun notifyConnectionChange(connected: Boolean, deviceName: String?) {
+ CoroutineScope(Dispatchers.Main).launch {
+ onConnectionStateChanged?.invoke(connected, deviceName)
+ }
+ }
+
+ fun getCurrentConnectionState(): ConnectionState = _connectionState.value
+
+ fun hasActiveConnection(): Boolean {
+ val connectedDevices = bluetoothHelper?.getConnectedDevices() ?: emptyList()
+ val hasConnection = connectedDevices.isNotEmpty()
+ if (hasConnection != _isConnected.value) {
+ _isConnected.value = hasConnection
+ if (!hasConnection) {
+ _connectedDeviceName.value = null
+ _connectionState.value = ConnectionState.Disconnected
+ }
+ }
+ return hasConnection
+ }
+
+ fun cleanup() {
+ bluetoothHelper?.cleanup()
+ bluetoothHelper = null
+ isInitialized = false
+ _isConnected.value = false
+ _connectedDeviceName.value = null
+ _connectionState.value = ConnectionState.Disconnected
+ _pairingState.value = PairingState.Idle
+ }
+}
diff --git a/app/src/main/java/com/sameerasw/airsync/utils/CallAudioManager.kt b/app/src/main/java/com/sameerasw/airsync/utils/CallAudioManager.kt
new file mode 100644
index 0000000..e8c59c3
--- /dev/null
+++ b/app/src/main/java/com/sameerasw/airsync/utils/CallAudioManager.kt
@@ -0,0 +1,129 @@
+package com.sameerasw.airsync.utils
+
+import android.annotation.SuppressLint
+import android.content.Context
+import android.media.AudioFormat
+import android.media.AudioRecord
+import android.media.AudioTrack
+import android.media.MediaRecorder
+import android.util.Base64
+import android.util.Log
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.isActive
+import kotlinx.coroutines.launch
+
+object CallAudioManager {
+ private const val TAG = "CallAudioManager"
+ private const val SAMPLE_RATE = 8000
+ private const val CHANNEL_CONFIG_IN = AudioFormat.CHANNEL_IN_MONO
+ private const val CHANNEL_CONFIG_OUT = AudioFormat.CHANNEL_OUT_MONO
+ private const val AUDIO_FORMAT = AudioFormat.ENCODING_PCM_16BIT
+
+ private var audioTrack: AudioTrack? = null
+ private var audioRecord: AudioRecord? = null
+ private var recordingJob: Job? = null
+ private var isRunning = false
+
+ @SuppressLint("MissingPermission")
+ fun startCallAudio(context: Context) {
+ if (isRunning) return
+ isRunning = true
+ Log.d(TAG, "Starting call audio...")
+
+ try {
+ // Setup AudioTrack for playback
+ val minBufferSizeOut = AudioTrack.getMinBufferSize(SAMPLE_RATE, CHANNEL_CONFIG_OUT, AUDIO_FORMAT)
+ audioTrack = AudioTrack.Builder()
+ .setAudioAttributes(
+ android.media.AudioAttributes.Builder()
+ .setUsage(android.media.AudioAttributes.USAGE_VOICE_COMMUNICATION)
+ .setContentType(android.media.AudioAttributes.CONTENT_TYPE_SPEECH)
+ .build()
+ )
+ .setAudioFormat(
+ AudioFormat.Builder()
+ .setEncoding(AUDIO_FORMAT)
+ .setSampleRate(SAMPLE_RATE)
+ .setChannelMask(CHANNEL_CONFIG_OUT)
+ .build()
+ )
+ .setBufferSizeInBytes(minBufferSizeOut)
+ .setTransferMode(AudioTrack.MODE_STREAM)
+ .build()
+
+ audioTrack?.play()
+
+ // Setup AudioRecord for capture
+ val minBufferSizeIn = AudioRecord.getMinBufferSize(SAMPLE_RATE, CHANNEL_CONFIG_IN, AUDIO_FORMAT)
+ audioRecord = AudioRecord(
+ MediaRecorder.AudioSource.VOICE_COMMUNICATION,
+ SAMPLE_RATE,
+ CHANNEL_CONFIG_IN,
+ AUDIO_FORMAT,
+ minBufferSizeIn
+ )
+
+ audioRecord?.startRecording()
+
+ // Start recording loop
+ recordingJob = CoroutineScope(Dispatchers.IO).launch {
+ val buffer = ByteArray(minBufferSizeIn)
+ while (isActive && isRunning) {
+ val read = audioRecord?.read(buffer, 0, buffer.size) ?: 0
+ if (read > 0) {
+ val base64 = Base64.encodeToString(buffer, 0, read, Base64.NO_WRAP)
+ sendMicAudio(base64)
+ }
+ }
+ }
+ } catch (e: Exception) {
+ Log.e(TAG, "Error starting call audio: ${e.message}")
+ stopCallAudio()
+ }
+ }
+
+ fun stopCallAudio() {
+ if (!isRunning) return
+ isRunning = false
+ Log.d(TAG, "Stopping call audio...")
+
+ recordingJob?.cancel()
+ recordingJob = null
+
+ try {
+ audioRecord?.stop()
+ audioRecord?.release()
+ audioRecord = null
+
+ audioTrack?.stop()
+ audioTrack?.release()
+ audioTrack = null
+ } catch (e: Exception) {
+ Log.e(TAG, "Error stopping audio: ${e.message}")
+ }
+ }
+
+ fun playReceivedAudio(base64Audio: String) {
+ if (!isRunning || audioTrack == null) return
+ try {
+ val audioData = Base64.decode(base64Audio, Base64.NO_WRAP)
+ audioTrack?.write(audioData, 0, audioData.size)
+ } catch (e: Exception) {
+ Log.e(TAG, "Error playing audio: ${e.message}")
+ }
+ }
+
+ private fun sendMicAudio(base64Audio: String) {
+ val message = """
+ {
+ "type": "callMicAudio",
+ "data": {
+ "audio": "$base64Audio"
+ }
+ }
+ """.trimIndent()
+ WebSocketUtil.sendMessage(message)
+ }
+}
diff --git a/app/src/main/java/com/sameerasw/airsync/utils/CallLogUtil.kt b/app/src/main/java/com/sameerasw/airsync/utils/CallLogUtil.kt
new file mode 100644
index 0000000..7c5f74e
--- /dev/null
+++ b/app/src/main/java/com/sameerasw/airsync/utils/CallLogUtil.kt
@@ -0,0 +1,208 @@
+package com.sameerasw.airsync.utils
+
+import android.Manifest
+import android.content.Context
+import android.content.pm.PackageManager
+import android.provider.CallLog
+import android.provider.ContactsContract
+import android.net.Uri
+import android.util.Log
+import androidx.core.content.ContextCompat
+import com.sameerasw.airsync.models.CallLogEntry
+
+object CallLogUtil {
+ private const val TAG = "CallLogUtil"
+
+ fun hasPermissions(context: Context): Boolean {
+ return ContextCompat.checkSelfPermission(
+ context,
+ Manifest.permission.READ_CALL_LOG
+ ) == PackageManager.PERMISSION_GRANTED &&
+ ContextCompat.checkSelfPermission(
+ context,
+ Manifest.permission.READ_CONTACTS
+ ) == PackageManager.PERMISSION_GRANTED
+ }
+
+ fun getCallLogs(context: Context, limit: Int = 100): List {
+ if (!hasPermissions(context)) {
+ Log.w(TAG, "Call log permissions not granted")
+ return emptyList()
+ }
+
+ val callLogs = mutableListOf()
+ val uri = CallLog.Calls.CONTENT_URI
+ val projection = arrayOf(
+ CallLog.Calls._ID,
+ CallLog.Calls.NUMBER,
+ CallLog.Calls.TYPE,
+ CallLog.Calls.DATE,
+ CallLog.Calls.DURATION,
+ CallLog.Calls.IS_READ
+ )
+
+ try {
+ // Don't use LIMIT in sort order - it causes "Invalid token LIMIT" error on some devices
+ context.contentResolver.query(
+ uri,
+ projection,
+ null,
+ null,
+ "${CallLog.Calls.DATE} DESC"
+ )?.use { cursor ->
+ var count = 0
+ while (cursor.moveToNext() && count < limit) {
+ try {
+ val id = cursor.getString(cursor.getColumnIndexOrThrow(CallLog.Calls._ID))
+ val number = cursor.getString(cursor.getColumnIndexOrThrow(CallLog.Calls.NUMBER)) ?: ""
+ val type = cursor.getInt(cursor.getColumnIndexOrThrow(CallLog.Calls.TYPE))
+ val date = cursor.getLong(cursor.getColumnIndexOrThrow(CallLog.Calls.DATE))
+ val duration = cursor.getLong(cursor.getColumnIndexOrThrow(CallLog.Calls.DURATION))
+ val isRead = cursor.getInt(cursor.getColumnIndexOrThrow(CallLog.Calls.IS_READ)) == 1
+
+ val contactName = getContactName(context, number)
+
+ callLogs.add(
+ CallLogEntry(
+ id = id,
+ number = number,
+ contactName = contactName,
+ type = type,
+ date = date,
+ duration = duration,
+ isRead = isRead
+ )
+ )
+ count++
+ } catch (e: Exception) {
+ Log.e(TAG, "Error processing call log entry", e)
+ }
+ }
+ }
+ } catch (e: Exception) {
+ Log.e(TAG, "Error reading call logs", e)
+ }
+
+ return callLogs
+ }
+
+ fun getCallLogsSince(context: Context, sinceTimestamp: Long): List {
+ if (!hasPermissions(context)) {
+ Log.w(TAG, "Call log permissions not granted")
+ return emptyList()
+ }
+
+ val callLogs = mutableListOf()
+ val uri = CallLog.Calls.CONTENT_URI
+ val projection = arrayOf(
+ CallLog.Calls._ID,
+ CallLog.Calls.NUMBER,
+ CallLog.Calls.TYPE,
+ CallLog.Calls.DATE,
+ CallLog.Calls.DURATION,
+ CallLog.Calls.IS_READ
+ )
+
+ try {
+ context.contentResolver.query(
+ uri,
+ projection,
+ "${CallLog.Calls.DATE} > ?",
+ arrayOf(sinceTimestamp.toString()),
+ "${CallLog.Calls.DATE} DESC"
+ )?.use { cursor ->
+ while (cursor.moveToNext()) {
+ val id = cursor.getString(cursor.getColumnIndexOrThrow(CallLog.Calls._ID))
+ val number = cursor.getString(cursor.getColumnIndexOrThrow(CallLog.Calls.NUMBER)) ?: ""
+ val type = cursor.getInt(cursor.getColumnIndexOrThrow(CallLog.Calls.TYPE))
+ val date = cursor.getLong(cursor.getColumnIndexOrThrow(CallLog.Calls.DATE))
+ val duration = cursor.getLong(cursor.getColumnIndexOrThrow(CallLog.Calls.DURATION))
+ val isRead = cursor.getInt(cursor.getColumnIndexOrThrow(CallLog.Calls.IS_READ)) == 1
+
+ val contactName = getContactName(context, number)
+
+ callLogs.add(
+ CallLogEntry(
+ id = id,
+ number = number,
+ contactName = contactName,
+ type = type,
+ date = date,
+ duration = duration,
+ isRead = isRead
+ )
+ )
+ }
+ }
+ } catch (e: Exception) {
+ Log.e(TAG, "Error reading call logs since timestamp", e)
+ }
+
+ return callLogs
+ }
+
+ fun markAsRead(context: Context, callId: String): Boolean {
+ if (!hasPermissions(context)) {
+ return false
+ }
+
+ return try {
+ val values = android.content.ContentValues().apply {
+ put(CallLog.Calls.IS_READ, 1)
+ }
+
+ context.contentResolver.update(
+ CallLog.Calls.CONTENT_URI,
+ values,
+ "${CallLog.Calls._ID} = ?",
+ arrayOf(callId)
+ )
+
+ true
+ } catch (e: Exception) {
+ Log.e(TAG, "Error marking call log as read", e)
+ false
+ }
+ }
+
+ fun getCallTypeString(type: Int): String {
+ return when (type) {
+ CallLog.Calls.INCOMING_TYPE -> "incoming"
+ CallLog.Calls.OUTGOING_TYPE -> "outgoing"
+ CallLog.Calls.MISSED_TYPE -> "missed"
+ CallLog.Calls.VOICEMAIL_TYPE -> "voicemail"
+ CallLog.Calls.REJECTED_TYPE -> "rejected"
+ CallLog.Calls.BLOCKED_TYPE -> "blocked"
+ else -> "unknown"
+ }
+ }
+
+ private fun getContactName(context: Context, phoneNumber: String): String? {
+ if (ContextCompat.checkSelfPermission(
+ context,
+ Manifest.permission.READ_CONTACTS
+ ) != PackageManager.PERMISSION_GRANTED
+ ) {
+ return null
+ }
+
+ val uri = Uri.withAppendedPath(
+ ContactsContract.PhoneLookup.CONTENT_FILTER_URI,
+ Uri.encode(phoneNumber)
+ )
+
+ val projection = arrayOf(ContactsContract.PhoneLookup.DISPLAY_NAME)
+
+ try {
+ context.contentResolver.query(uri, projection, null, null, null)?.use { cursor ->
+ if (cursor.moveToFirst()) {
+ return cursor.getString(cursor.getColumnIndexOrThrow(ContactsContract.PhoneLookup.DISPLAY_NAME))
+ }
+ }
+ } catch (e: Exception) {
+ Log.e(TAG, "Error getting contact name", e)
+ }
+
+ return null
+ }
+}
diff --git a/app/src/main/java/com/sameerasw/airsync/utils/ContactLookupHelper.kt b/app/src/main/java/com/sameerasw/airsync/utils/ContactLookupHelper.kt
index ae104d7..ea5d938 100644
--- a/app/src/main/java/com/sameerasw/airsync/utils/ContactLookupHelper.kt
+++ b/app/src/main/java/com/sameerasw/airsync/utils/ContactLookupHelper.kt
@@ -62,13 +62,33 @@ class ContactLookupHelper(private val context: Context) {
}
/**
- * Get device's country code from locale
+ * Get device's country code from SIM card or locale
*/
private fun getDeviceCountryCode(): String {
return try {
- context.resources.configuration.locale.country.takeIf { it.isNotEmpty() } ?: "US"
+ // First try to get country from SIM card (most accurate for phone numbers)
+ val telephonyManager = context.getSystemService(Context.TELEPHONY_SERVICE) as? android.telephony.TelephonyManager
+ val simCountry = telephonyManager?.simCountryIso?.uppercase()
+ if (!simCountry.isNullOrEmpty()) {
+ return simCountry
+ }
+
+ // Fallback to network country
+ val networkCountry = telephonyManager?.networkCountryIso?.uppercase()
+ if (!networkCountry.isNullOrEmpty()) {
+ return networkCountry
+ }
+
+ // Fallback to device locale
+ context.resources.configuration.locales[0].country.takeIf { it.isNotEmpty() } ?: "US"
} catch (e: Exception) {
- "US" // Fallback
+ // Final fallback - try deprecated locale method
+ try {
+ @Suppress("DEPRECATION")
+ context.resources.configuration.locale.country.takeIf { it.isNotEmpty() } ?: "US"
+ } catch (e2: Exception) {
+ "US"
+ }
}
}
diff --git a/app/src/main/java/com/sameerasw/airsync/utils/FileReceiveManager.kt b/app/src/main/java/com/sameerasw/airsync/utils/FileReceiveManager.kt
new file mode 100644
index 0000000..fddb31c
--- /dev/null
+++ b/app/src/main/java/com/sameerasw/airsync/utils/FileReceiveManager.kt
@@ -0,0 +1,254 @@
+package com.sameerasw.airsync.utils
+
+import android.content.ContentValues
+import android.content.Context
+import android.net.Uri
+import android.os.Build
+import android.os.Environment
+import android.provider.MediaStore
+import android.util.Base64
+import android.util.Log
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import java.io.File
+import java.io.FileOutputStream
+import java.io.OutputStream
+import java.security.MessageDigest
+
+object FileReceiveManager {
+ private const val TAG = "FileReceiveManager"
+
+ // Store ongoing file transfers (lightweight - no chunk data stored)
+ private val activeTransfers = java.util.concurrent.ConcurrentHashMap()
+
+ // Expose active transfers as StateFlow for UI
+ private val _activeTransfersFlow = MutableStateFlow