diff --git a/.gitignore b/.gitignore index 8d589cd4..9939654b 100644 --- a/.gitignore +++ b/.gitignore @@ -35,3 +35,4 @@ app/release local.properties .vscode/launch.json build/reports/problems/problems-report.html +.agents/ \ No newline at end of file diff --git a/app/build.gradle.kts b/app/build.gradle.kts index db4c1464..23d0390c 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -11,14 +11,13 @@ plugins { android { namespace = "com.sameerasw.airsync" - compileSdk = 36 + compileSdk = 37 defaultConfig { applicationId = "com.sameerasw.airsync" minSdk = 30 - targetSdk = 36 - versionCode = 27 - versionName = "3.1.0" + versionCode = 29 + versionName = "4.0.0" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" } @@ -47,21 +46,23 @@ android { } } compileOptions { - sourceCompatibility = VERSION_11 - targetCompatibility = VERSION_17 + sourceCompatibility = JavaVersion.VERSION_21 + targetCompatibility = JavaVersion.VERSION_21 } kotlin { compilerOptions { - jvmTarget.set(JvmTarget.JVM_17) + jvmTarget.set(JvmTarget.JVM_21) } } buildFeatures { compose = true buildConfig = true } + compileSdkMinor = 0 defaultConfig { - buildConfigField("String", "MIN_MAC_APP_VERSION", "\"3.0.0\"") + targetSdk = 37 + buildConfigField("String", "MIN_MAC_APP_VERSION", "\"4.0.0\"") } } @@ -154,6 +155,14 @@ dependencies { implementation(libs.wire.runtime) implementation(libs.bouncycastle) + + // Ktor Server for WebDAV + implementation(libs.ktor.server.core) + implementation(libs.ktor.server.cio) + implementation(libs.ktor.server.host.common) + implementation(libs.ktor.server.status.pages) + implementation(libs.ktor.server.content.negotiation) + implementation(libs.ktor.serialization.gson) } wire { diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro index 4246abbd..2f40cbfd 100644 --- a/app/proguard-rules.pro +++ b/app/proguard-rules.pro @@ -29,4 +29,9 @@ -keep class com.sameerasw.airsync.domain.model.** { *; } # Data Layer --keep class com.sameerasw.airsync.data.** { *; } \ No newline at end of file +-keep class com.sameerasw.airsync.data.** { *; } + +# Ktor & SLF4J missing classes on Android +-dontwarn java.lang.management.ManagementFactory +-dontwarn java.lang.management.RuntimeMXBean +-dontwarn org.slf4j.impl.StaticLoggerBinder \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 973848ef..a9f19d1a 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -12,6 +12,14 @@ + + + + + + + + @@ -38,6 +46,7 @@ + diff --git a/app/src/main/java/com/sameerasw/airsync/AirSyncApp.kt b/app/src/main/java/com/sameerasw/airsync/AirSyncApp.kt index 085c026f..a01c427b 100644 --- a/app/src/main/java/com/sameerasw/airsync/AirSyncApp.kt +++ b/app/src/main/java/com/sameerasw/airsync/AirSyncApp.kt @@ -10,16 +10,21 @@ import kotlinx.coroutines.runBlocking class AirSyncApp : Application() { private var activityCount = 0 + private lateinit var bleConnectionManager: com.sameerasw.airsync.data.ble.BleConnectionManager companion object { private var instance: AirSyncApp? = null fun isAppForeground(): Boolean = instance?.isForeground() ?: false + fun getBleConnectionManager(): com.sameerasw.airsync.data.ble.BleConnectionManager? = instance?.bleConnectionManager } override fun onCreate() { super.onCreate() instance = this initSentry() + + bleConnectionManager = com.sameerasw.airsync.data.ble.BleConnectionManager(this) + bleConnectionManager.start() registerActivityLifecycleCallbacks(object : ActivityLifecycleCallbacks { override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) {} override fun onActivityStarted(activity: Activity) { diff --git a/app/src/main/java/com/sameerasw/airsync/MainActivity.kt b/app/src/main/java/com/sameerasw/airsync/MainActivity.kt index df8c8470..c9848bc0 100644 --- a/app/src/main/java/com/sameerasw/airsync/MainActivity.kt +++ b/app/src/main/java/com/sameerasw/airsync/MainActivity.kt @@ -60,6 +60,7 @@ import com.sameerasw.airsync.utils.KeyguardHelper import com.sameerasw.airsync.utils.NotesRoleManager import com.sameerasw.airsync.utils.PermissionUtil import com.sameerasw.airsync.utils.ShortcutUtil +import com.sameerasw.airsync.utils.UDPDiscoveryManager import com.sameerasw.airsync.utils.WebSocketUtil import kotlinx.coroutines.flow.first import kotlinx.coroutines.runBlocking @@ -339,8 +340,12 @@ class MainActivity : ComponentActivity() { handleNotesRoleIntent(intent) // Start ADB discovery once at app startup and keep it running - AdbDiscoveryHolder.initialize(this) - Log.d("MainActivity", "Started persistent ADB discovery at app startup") + if (PermissionUtil.isLocalNetworkPermissionGranted(this)) { + AdbDiscoveryHolder.initialize(this) + Log.d("MainActivity", "Started persistent ADB discovery at app startup") + } else { + Log.d("MainActivity", "Skipping persistent ADB discovery at startup: ACCESS_LOCAL_NETWORK permission not granted") + } // Check if this is a QS tile long-press intent and device is not connected if (intent?.action == "android.service.quicksettings.action.QS_TILE_PREFERENCES") { @@ -581,11 +586,26 @@ class MainActivity : ComponentActivity() { } } + override fun onResume() { + super.onResume() + if (PermissionUtil.isLocalNetworkPermissionGranted(this)) { + AdbDiscoveryHolder.initialize(this) + val ds = DataStoreManager.getInstance(applicationContext) + val isDiscoveryEnabled = runBlocking { + ds.getDeviceDiscoveryEnabled().first() + } + UDPDiscoveryManager.start(this, isDiscoveryEnabled) + UDPDiscoveryManager.burstBroadcast(this) + } + } + /** * Ensure ADB discovery is running (started at app startup, this just verifies it's active). */ fun initializeAdbDiscovery() { - AdbDiscoveryHolder.initialize(this) + if (PermissionUtil.isLocalNetworkPermissionGranted(this)) { + AdbDiscoveryHolder.initialize(this) + } } /** diff --git a/app/src/main/java/com/sameerasw/airsync/data/ble/BleChunkUtil.kt b/app/src/main/java/com/sameerasw/airsync/data/ble/BleChunkUtil.kt new file mode 100644 index 00000000..7504492c --- /dev/null +++ b/app/src/main/java/com/sameerasw/airsync/data/ble/BleChunkUtil.kt @@ -0,0 +1,115 @@ +package com.sameerasw.airsync.data.ble + +import android.util.Log + +object BleChunkUtil { + private const val TAG = "BleChunkUtil" + + /** + * Splits a string payload into chunks suitable for BLE transmission. + * Each chunk starts with a 2-byte header: [currentIndex, totalChunks] + */ + fun splitIntoChunks(payload: String, mtu: Int): List { + val data = payload.toByteArray(Charsets.UTF_8) + val maxPayloadSize = mtu - BleConstants.CHUNK_HEADER_SIZE + + if (maxPayloadSize <= 0) { + Log.e(TAG, "MTU too small: $mtu") + return emptyList() + } + + val totalChunks = (data.size + maxPayloadSize - 1) / maxPayloadSize + val chunks = mutableListOf() + + for (i in 0 until totalChunks) { + val start = i * maxPayloadSize + val end = minOf(start + maxPayloadSize, data.size) + val chunkData = data.sliceArray(start until end) + + val chunk = ByteArray(BleConstants.CHUNK_HEADER_SIZE + chunkData.size) + val buffer = java.nio.ByteBuffer.wrap(chunk) + buffer.putShort(i.toShort()) + buffer.putShort(totalChunks.toShort()) + + chunkData.copyInto(chunk, BleConstants.CHUNK_HEADER_SIZE) + chunks.add(chunk) + } + + return chunks + } + + /** + * Reassembles chunks into the original string. + * Expects a map of index to chunk data (without the header). + */ + fun reassemble(chunks: Map): String { + val sortedIndices = chunks.keys.sorted() + if (sortedIndices.isEmpty()) return "" + + val totalSize = chunks.values.sumOf { it.size } + val result = ByteArray(totalSize) + + var offset = 0 + for (index in sortedIndices) { + val chunk = chunks[index] ?: continue + chunk.copyInto(result, offset) + offset += chunk.size + } + + return String(result, Charsets.UTF_8) + } + + /** + * Extracts header information from a raw BLE packet. + */ + fun parseHeader(packet: ByteArray): Pair? { + if (packet.size < BleConstants.CHUNK_HEADER_SIZE) return null + val buffer = java.nio.ByteBuffer.wrap(packet) + val current = buffer.short.toInt() and 0xFFFF + val total = buffer.short.toInt() and 0xFFFF + return Pair(current, total) + } + + /** + * Extracts payload data from a raw BLE packet (strips header). + */ + fun getPayload(packet: ByteArray): ByteArray { + if (packet.size <= BleConstants.CHUNK_HEADER_SIZE) return byteArrayOf() + return packet.sliceArray(BleConstants.CHUNK_HEADER_SIZE until packet.size) + } + + /** + * Helper class to reassemble chunks as they arrive. + */ + class Reassembler { + private val chunks = mutableMapOf() + private var totalChunks = -1 + + fun addChunk(packet: ByteArray): String? { + val header = parseHeader(packet) ?: return null + val current = header.first + val total = header.second + + if (totalChunks != -1 && totalChunks != total) { + // New transmission started or mismatch, reset + chunks.clear() + } + totalChunks = total + + chunks[current] = getPayload(packet) + + if (chunks.size == totalChunks) { + val result = reassemble(chunks) + chunks.clear() + totalChunks = -1 + return result + } + return null + } + + fun clear() { + chunks.clear() + totalChunks = -1 + } + } +} diff --git a/app/src/main/java/com/sameerasw/airsync/data/ble/BleConnectionManager.kt b/app/src/main/java/com/sameerasw/airsync/data/ble/BleConnectionManager.kt new file mode 100644 index 00000000..a74882b0 --- /dev/null +++ b/app/src/main/java/com/sameerasw/airsync/data/ble/BleConnectionManager.kt @@ -0,0 +1,90 @@ +package com.sameerasw.airsync.data.ble + +import android.content.Context +import android.util.Log +import com.sameerasw.airsync.data.local.DataStoreManager +import com.sameerasw.airsync.utils.WebSocketUtil +import kotlinx.coroutines.* +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.flatMapLatest + +class BleConnectionManager(private val context: Context) { + companion object { + private const val TAG = "BleConnectionManager" + } + + private val scope = CoroutineScope(Dispatchers.Main + SupervisorJob()) + private val dataStoreManager = DataStoreManager(context) + private var bleServer: BleGattServer? = null + + private var isBleEnabled = false + + @OptIn(ExperimentalCoroutinesApi::class) + private val _serverFlow = kotlinx.coroutines.flow.MutableStateFlow(null) + + @OptIn(ExperimentalCoroutinesApi::class) + val connectionState = _serverFlow.flatMapLatest { server -> + server?.connectionState ?: kotlinx.coroutines.flow.MutableStateFlow(BleGattServer.BleConnectionState.DISCONNECTED) + } + + fun start() { + if (bleServer == null) { + bleServer = BleGattServer(context) + _serverFlow.value = bleServer + BleTransportBridge.initialize(bleServer!!) + } + + scope.launch { + combine( + dataStoreManager.getBleSyncEnabled(), + dataStoreManager.getBleAutoConnectEnabled(), + WebSocketUtil.connectionState + ) { enabled, auto, wsConnected -> + Triple(enabled, auto, wsConnected) + }.collectLatest { (enabled, _, wsConnected) -> + isBleEnabled = enabled + updateBleState(regularConnectionActive = wsConnected) + } + } + } + + private fun updateBleState(regularConnectionActive: Boolean) { + if (!isBleEnabled) { + Log.d(TAG, "BLE disabled, stopping server") + bleServer?.stop() + return + } + + if (regularConnectionActive) { + // Regular Wi-Fi/USB connection is up — pause advertising to save power. + Log.d(TAG, "Regular connection active — pausing BLE advertising") + bleServer?.pauseAdvertising() + } else { + // No regular connection — ensure server is started and advertising. + Log.d(TAG, "No regular connection — resuming BLE advertising") + bleServer?.start() + bleServer?.resumeAdvertising() + } + } + + fun stop() { + scope.cancel() + bleServer?.stop() + } + + val isAuthenticated: Boolean + get() = bleServer?.isAuthenticated ?: false + + fun sendChunkedNotification(characteristicUuid: java.util.UUID, payload: String) { + bleServer?.sendChunkedNotification(characteristicUuid, payload) + } + + fun sendNotification(characteristicUuid: java.util.UUID, data: ByteArray) { + bleServer?.sendNotification(characteristicUuid, data) + } + + fun disconnectAllConnectedDevices() { + bleServer?.disconnectAllConnectedDevices() + } +} diff --git a/app/src/main/java/com/sameerasw/airsync/data/ble/BleConstants.kt b/app/src/main/java/com/sameerasw/airsync/data/ble/BleConstants.kt new file mode 100644 index 00000000..78d5c7c9 --- /dev/null +++ b/app/src/main/java/com/sameerasw/airsync/data/ble/BleConstants.kt @@ -0,0 +1,50 @@ +package com.sameerasw.airsync.data.ble + +import java.util.UUID + +object BleConstants { + private const val UUID_BASE = "-7461-4694-8146-2162624a682c" + + // Services + val SERVICE_SYSTEM = UUID.fromString("a1520001$UUID_BASE") + val SERVICE_NOTIFICATIONS = UUID.fromString("a1520002$UUID_BASE") + val SERVICE_MEDIA = UUID.fromString("a1520003$UUID_BASE") + val SERVICE_CLIPBOARD = UUID.fromString("a1520004$UUID_BASE") + + // System Characteristics + val CHAR_PROTOCOL_VERSION = UUID.fromString("a1520101$UUID_BASE") + val CHAR_AUTH_TOKEN = UUID.fromString("a1520102$UUID_BASE") + val CHAR_AUTH_RESULT = UUID.fromString("a1520103$UUID_BASE") + val CHAR_BATTERY_LEVEL = UUID.fromString("a1520104$UUID_BASE") + val CHAR_MAC_BATTERY = UUID.fromString("a1520105$UUID_BASE") + val CHAR_SYSTEM_STATE = UUID.fromString("a1520106$UUID_BASE") + val CHAR_MAC_CONTROL = UUID.fromString("a1520107$UUID_BASE") + val CHAR_DEVICE_NAME = UUID.fromString("a1520108$UUID_BASE") + + // Notification Characteristics + val CHAR_NOTIFICATION_DATA = UUID.fromString("a1520201$UUID_BASE") + val CHAR_NOTIFICATION_ACTION = UUID.fromString("a1520202$UUID_BASE") + val CHAR_NOTIFICATION_DISMISS = UUID.fromString("a1520203$UUID_BASE") + val CHAR_NOTIFICATION_DISMISS_NOTIFY = UUID.fromString("a1520204$UUID_BASE") + + // Media Characteristics + val CHAR_MEDIA_STATE = UUID.fromString("a1520301$UUID_BASE") + val CHAR_MEDIA_CONTROL = UUID.fromString("a1520302$UUID_BASE") + val CHAR_MAC_MEDIA_STATE = UUID.fromString("a1520303$UUID_BASE") + + // Clipboard Characteristics + val CHAR_CLIPBOARD_DATA_NOTIFY = UUID.fromString("a1520401$UUID_BASE") + val CHAR_CLIPBOARD_DATA_WRITE = UUID.fromString("a1520402$UUID_BASE") + + // Protocol Constants + const val PROTOCOL_VERSION = 1 + const val AUTH_SUCCESS: Byte = 0x01 + const val AUTH_FAILED: Byte = 0x00 + + // Chunking + const val MAX_MTU = 512 + const val CHUNK_HEADER_SIZE = 4 // [index: UInt16][total: UInt16] + + // Delimiter for compact strings + const val DELIMITER = "\u001F" +} diff --git a/app/src/main/java/com/sameerasw/airsync/data/ble/BleGattServer.kt b/app/src/main/java/com/sameerasw/airsync/data/ble/BleGattServer.kt new file mode 100644 index 00000000..671ec295 --- /dev/null +++ b/app/src/main/java/com/sameerasw/airsync/data/ble/BleGattServer.kt @@ -0,0 +1,587 @@ +package com.sameerasw.airsync.data.ble + +import android.annotation.SuppressLint +import android.bluetooth.* +import android.bluetooth.le.AdvertiseCallback +import android.bluetooth.le.AdvertiseData +import android.bluetooth.le.AdvertiseSettings +import android.content.Context +import android.os.ParcelUuid +import android.util.Log +import com.sameerasw.airsync.data.local.DataStoreManager +import com.sameerasw.airsync.utils.MacDeviceStatusManager +import com.sameerasw.airsync.utils.NotificationDismissalUtil +import com.sameerasw.airsync.utils.ClipboardSyncManager +import kotlinx.coroutines.* +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.first +import java.util.* +import java.util.concurrent.ConcurrentLinkedQueue + +@SuppressLint("MissingPermission") +class BleGattServer(private val context: Context) { + companion object { + private const val TAG = "BleGattServer" + private var instance: BleGattServer? = null + fun isAnyAuthenticated(): Boolean = instance?.isAuthenticated ?: false + } + + init { + instance = this + } + + private val bluetoothManager = context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager + private val adapter = bluetoothManager.adapter + private var gattServer: BluetoothGattServer? = null + private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + private val dataStoreManager = DataStoreManager(context) + + private val _connectionState = MutableStateFlow(BleConnectionState.DISCONNECTED) + val connectionState = _connectionState.asStateFlow() + + private val connectedDevices = mutableSetOf() + private val characteristicQueues = mutableMapOf>() + private val isSending = mutableMapOf() + + var isAuthenticated = false + private set + private var negotiatedMtu = 23 + private var heartbeatJob: Job? = null + private var isAdvertisingPaused = false + + enum class BleConnectionState { + DISCONNECTED, ADVERTISING, CONNECTED, AUTHENTICATED + } + + private val pendingServices = mutableListOf() + + /** + * Start the GATT server and begin advertising + */ + fun start() { + if (_connectionState.value != BleConnectionState.DISCONNECTED) { + Log.d(TAG, "BLE GATT Server already starting or started") + return + } + + if (!com.sameerasw.airsync.utils.PermissionUtil.isBluetoothPermissionsGranted(context)) { + Log.e(TAG, "Missing Bluetooth permissions, cannot start BLE transport") + return + } + + if (adapter == null || !adapter.isEnabled) { + Log.e(TAG, "Bluetooth adapter not available or disabled") + return + } + + val isEnabled = try { + runBlocking { dataStoreManager.getBleSyncEnabled().first() } + } catch (e: Exception) { + false + } + if (!isEnabled) { + Log.d(TAG, "BLE Sync is disabled in settings, skipping start") + return + } + + // Set Bluetooth adapter name dynamically based on configured device name to keep BLE matching precise + val customName = try { + runBlocking { dataStoreManager.getDeviceName().first() } + } catch (e: Exception) { + "" + } + val rawName = if (customName.isNotBlank()) customName else com.sameerasw.airsync.utils.DeviceInfoUtil.getDeviceName(context) + val baseName = rawName + .replace("AirSync-AirSync-", "") + .replace("AirSync-", "") + .replace("airsync-", "") + .replace("airsync", "") + .trim() + + val bleName = "AirSync-$baseName" + try { + if (adapter.name != bleName) { + adapter.name = bleName + Log.d(TAG, "Updated Bluetooth adapter name to: $bleName") + } + } catch (e: Exception) { + Log.w(TAG, "Failed to set Bluetooth adapter name: ${e.message}") + } + + setupGattServer() + } + + /** + * Stop the GATT server and advertising + */ + fun stop() { + stopAdvertising() + stopHeartbeat() + gattServer?.clearServices() + gattServer?.close() + gattServer = null + connectedDevices.clear() + pendingServices.clear() + _connectionState.value = BleConnectionState.DISCONNECTED + isAuthenticated = false + isAdvertisingPaused = false + } + + private fun setupGattServer() { + gattServer = bluetoothManager.openGattServer(context, gattServerCallback) + + pendingServices.clear() + + // System Service + val systemService = BluetoothGattService(BleConstants.SERVICE_SYSTEM, BluetoothGattService.SERVICE_TYPE_PRIMARY) + systemService.addCharacteristic(createReadCharacteristic(BleConstants.CHAR_PROTOCOL_VERSION)) + systemService.addCharacteristic(createWriteCharacteristic(BleConstants.CHAR_AUTH_TOKEN)) + systemService.addCharacteristic(createNotifyCharacteristic(BleConstants.CHAR_AUTH_RESULT)) + systemService.addCharacteristic(createNotifyCharacteristic(BleConstants.CHAR_BATTERY_LEVEL)) + systemService.addCharacteristic(createWriteCharacteristic(BleConstants.CHAR_MAC_BATTERY)) + systemService.addCharacteristic(createNotifyCharacteristic(BleConstants.CHAR_SYSTEM_STATE)) + systemService.addCharacteristic(createNotifyCharacteristic(BleConstants.CHAR_MAC_CONTROL)) + systemService.addCharacteristic(createNotifyCharacteristic(BleConstants.CHAR_DEVICE_NAME)) + pendingServices.add(systemService) + + // Notifications Service + val notifService = BluetoothGattService(BleConstants.SERVICE_NOTIFICATIONS, BluetoothGattService.SERVICE_TYPE_PRIMARY) + notifService.addCharacteristic(createNotifyCharacteristic(BleConstants.CHAR_NOTIFICATION_DATA)) + notifService.addCharacteristic(createWriteCharacteristic(BleConstants.CHAR_NOTIFICATION_ACTION)) + notifService.addCharacteristic(createWriteCharacteristic(BleConstants.CHAR_NOTIFICATION_DISMISS)) + notifService.addCharacteristic(createNotifyCharacteristic(BleConstants.CHAR_NOTIFICATION_DISMISS_NOTIFY)) + pendingServices.add(notifService) + + // Media Service + val mediaService = BluetoothGattService(BleConstants.SERVICE_MEDIA, BluetoothGattService.SERVICE_TYPE_PRIMARY) + mediaService.addCharacteristic(createNotifyCharacteristic(BleConstants.CHAR_MEDIA_STATE)) + mediaService.addCharacteristic(createWriteCharacteristic(BleConstants.CHAR_MEDIA_CONTROL)) + mediaService.addCharacteristic(createWriteCharacteristic(BleConstants.CHAR_MAC_MEDIA_STATE)) + pendingServices.add(mediaService) + + // Clipboard Service + val clipService = BluetoothGattService(BleConstants.SERVICE_CLIPBOARD, BluetoothGattService.SERVICE_TYPE_PRIMARY) + clipService.addCharacteristic(createNotifyCharacteristic(BleConstants.CHAR_CLIPBOARD_DATA_NOTIFY)) + clipService.addCharacteristic(createWriteCharacteristic(BleConstants.CHAR_CLIPBOARD_DATA_WRITE)) + pendingServices.add(clipService) + + // Add first service with a small delay for stability + scope.launch(Dispatchers.Main) { + delay(300) + if (pendingServices.isNotEmpty()) { + val first = pendingServices.removeAt(0) + gattServer?.addService(first) + } + } + } + + private var currentAdvertiseCallback: AdvertiseCallback? = null + + private fun startAdvertising() { + if (currentAdvertiseCallback != null) { + stopAdvertising() + } + + val advertiser = adapter.bluetoothLeAdvertiser ?: return + + val settings = AdvertiseSettings.Builder() + .setAdvertiseMode(AdvertiseSettings.ADVERTISE_MODE_BALANCED) + .setConnectable(true) + .setTimeout(0) + .setTxPowerLevel(AdvertiseSettings.ADVERTISE_TX_POWER_MEDIUM) + .build() + + val data = AdvertiseData.Builder() + .setIncludeDeviceName(false) + .addServiceUuid(ParcelUuid(BleConstants.SERVICE_SYSTEM)) + .build() + + val scanResponse = AdvertiseData.Builder() + .setIncludeDeviceName(true) + .build() + + val callback = object : AdvertiseCallback() { + override fun onStartSuccess(settingsInEffect: AdvertiseSettings?) { + Log.d(TAG, "BLE Advertising started successfully") + _connectionState.value = BleConnectionState.ADVERTISING + } + + override fun onStartFailure(errorCode: Int) { + if (errorCode == ADVERTISE_FAILED_ALREADY_STARTED) { + Log.d(TAG, "BLE Advertising already started, treating as success") + _connectionState.value = BleConnectionState.ADVERTISING + } else { + Log.e(TAG, "BLE Advertising failed: $errorCode") + currentAdvertiseCallback = null + _connectionState.value = BleConnectionState.DISCONNECTED + } + } + } + + currentAdvertiseCallback = callback + advertiser.startAdvertising(settings, data, scanResponse, callback) + } + + private fun stopAdvertising() { + val callback = currentAdvertiseCallback ?: return + adapter.bluetoothLeAdvertiser?.stopAdvertising(callback) + currentAdvertiseCallback = null + } + + /** + * Stop advertising while keeping the GATT server alive. + * Existing BLE connections remain active + */ + fun pauseAdvertising() { + if (isAdvertisingPaused) return + Log.d(TAG, "BLE advertising paused (regular connection active)") + isAdvertisingPaused = true + stopAdvertising() + if (_connectionState.value == BleConnectionState.ADVERTISING) { + _connectionState.value = BleConnectionState.CONNECTED + } + } + + /** + * Resume advertising after it was paused. No-op if already advertising. + */ + fun resumeAdvertising() { + if (!isAdvertisingPaused) return + if (gattServer == null) return + if (_connectionState.value == BleConnectionState.DISCONNECTED) return + Log.d(TAG, "BLE advertising resumed") + isAdvertisingPaused = false + startAdvertising() + } + + private val gattServerCallback = object : BluetoothGattServerCallback() { + override fun onServiceAdded(status: Int, service: BluetoothGattService) { + Log.d(TAG, "Service added: ${service.uuid}, status: $status") + if (pendingServices.isNotEmpty()) { + val next = pendingServices.removeAt(0) + gattServer?.addService(next) + } else { + Log.d(TAG, "All services added, starting advertising") + startAdvertising() + } + } + + override fun onConnectionStateChange(device: BluetoothDevice, status: Int, newState: Int) { + Log.d(TAG, "onConnectionStateChange: device=${device.address}, status=$status, newState=$newState, bond=${device.bondState}") + + if (newState == BluetoothProfile.STATE_CONNECTED) { + Log.d(TAG, "Device connected: ${device.address}") + connectedDevices.add(device) + _connectionState.value = BleConnectionState.CONNECTED + } else if (newState == BluetoothProfile.STATE_DISCONNECTED) { + Log.d(TAG, "Device disconnected: ${device.address}") + connectedDevices.remove(device) + if (connectedDevices.isEmpty()) { + stopHeartbeat() + _connectionState.value = if (gattServer != null) BleConnectionState.ADVERTISING else BleConnectionState.DISCONNECTED + isAuthenticated = false + if (gattServer != null) { + val isEnabled = try { + runBlocking { dataStoreManager.getBleSyncEnabled().first() } + } catch (e: Exception) { + false + } + if (isEnabled) { + if (!isAdvertisingPaused) { + startAdvertising() + } + } else { + Log.d(TAG, "BLE Sync is disabled, stopping server") + stop() + } + } + } + } + } + + override fun onMtuChanged(device: BluetoothDevice, mtu: Int) { + Log.d(TAG, "MTU changed for ${device.address}: $mtu") + negotiatedMtu = mtu + } + + override fun onCharacteristicReadRequest(device: BluetoothDevice, requestId: Int, offset: Int, characteristic: BluetoothGattCharacteristic) { + if (characteristic.uuid == BleConstants.CHAR_PROTOCOL_VERSION) { + gattServer?.sendResponse(device, requestId, BluetoothGatt.GATT_SUCCESS, 0, byteArrayOf(BleConstants.PROTOCOL_VERSION.toByte())) + } else { + gattServer?.sendResponse(device, requestId, BluetoothGatt.GATT_READ_NOT_PERMITTED, 0, null) + } + } + + private val chunkBuffers = mutableMapOf>() + + override fun onCharacteristicWriteRequest(device: BluetoothDevice, requestId: Int, characteristic: BluetoothGattCharacteristic, preparedWrite: Boolean, responseNeeded: Boolean, offset: Int, value: ByteArray) { + Log.d(TAG, "Write request for ${characteristic.uuid}, length: ${value.size}") + + if (characteristic.uuid != BleConstants.CHAR_AUTH_TOKEN && !isAuthenticated) { + Log.w(TAG, "Blocked unauthorized write request to ${characteristic.uuid} from ${device.address}") + if (responseNeeded) { + gattServer?.sendResponse(device, requestId, BluetoothGatt.GATT_WRITE_NOT_PERMITTED, offset, null) + } + return + } + + when (characteristic.uuid) { + BleConstants.CHAR_AUTH_TOKEN -> handleAuthRequest(device, value) + BleConstants.CHAR_MAC_BATTERY -> handleMacBattery(value) + BleConstants.CHAR_NOTIFICATION_ACTION -> handleChunkedWrite(characteristic.uuid, value) { handleNotificationAction(it.toByteArray(Charsets.UTF_8)) } + BleConstants.CHAR_MEDIA_CONTROL -> handleChunkedWrite(characteristic.uuid, value) { handleMediaControl(it.toByteArray(Charsets.UTF_8)) } + BleConstants.CHAR_MAC_MEDIA_STATE -> handleChunkedWrite(characteristic.uuid, value) { handleMacMediaState(it) } + BleConstants.CHAR_CLIPBOARD_DATA_WRITE -> handleChunkedWrite(characteristic.uuid, value) { + Log.d(TAG, "Received clipboard from Mac via BLE: ${it.take(50)}") + ClipboardSyncManager.handleClipboardUpdate(context, it) + } + BleConstants.CHAR_DEVICE_NAME -> handleChunkedWrite(characteristic.uuid, value) { + Log.d(TAG, "Received Mac Device Name: $it") + // Update Mac name in status manager + MacDeviceStatusManager.updateMacStatus(context, name = it) + } + BleConstants.CHAR_NOTIFICATION_DISMISS -> handleChunkedWrite(characteristic.uuid, value) { handleNotificationDismiss(it) } + else -> Log.w(TAG, "Unknown characteristic write: ${characteristic.uuid}") + } + + if (responseNeeded) { + gattServer?.sendResponse(device, requestId, BluetoothGatt.GATT_SUCCESS, offset, value) + } + } + + private fun handleChunkedWrite(uuid: UUID, value: ByteArray, onComplete: (String) -> Unit) { + val header = BleChunkUtil.parseHeader(value) + if (header == null) { + // Not chunked or invalid header - maybe small payload? + // For now, assume everything to these characteristics is chunked. + return + } + val (current, total) = header + val payload = BleChunkUtil.getPayload(value) + + val buffer = chunkBuffers.getOrPut(uuid) { mutableMapOf() } + buffer[current] = payload + + if (buffer.size == total) { + val completePayload = BleChunkUtil.reassemble(buffer) + chunkBuffers.remove(uuid) + onComplete(completePayload) + } + } + + override fun onDescriptorReadRequest(device: BluetoothDevice, requestId: Int, offset: Int, descriptor: BluetoothGattDescriptor) { + Log.d(TAG, "Descriptor read request: ${descriptor.uuid}") + gattServer?.sendResponse(device, requestId, BluetoothGatt.GATT_SUCCESS, offset, null) + } + + override fun onDescriptorWriteRequest(device: BluetoothDevice, requestId: Int, descriptor: BluetoothGattDescriptor, preparedWrite: Boolean, responseNeeded: Boolean, offset: Int, value: ByteArray) { + Log.d(TAG, "Descriptor write request: ${descriptor.uuid}, value: ${value.contentToString()}") + if (responseNeeded) { + gattServer?.sendResponse(device, requestId, BluetoothGatt.GATT_SUCCESS, offset, value) + } + } + + override fun onNotificationSent(device: BluetoothDevice, status: Int) { + // This is crucial for sequential chunk sending + processNextInQueues() + } + } + + private fun handleAuthRequest(device: BluetoothDevice, token: ByteArray) { + scope.launch { + val deviceData = dataStoreManager.getLastConnectedDevice().first() + val storedKey = deviceData?.symmetricKey + Log.d(TAG, "Handling auth request from ${device.address}. Device in DB: ${deviceData?.name}, hasKey: ${storedKey != null}") + + if (storedKey != null) { + val expectedToken = BleTransportBridge.deriveAuthToken(storedKey) + val receivedTokenStr = String(token, Charsets.UTF_8) + + Log.d(TAG, "Expected token: $expectedToken") + Log.d(TAG, "Received token: $receivedTokenStr") + + if (token.contentEquals(expectedToken.toByteArray(Charsets.UTF_8))) { + Log.i(TAG, "BLE Auth Success!") + isAuthenticated = true + _connectionState.value = BleConnectionState.AUTHENTICATED + sendNotification(BleConstants.CHAR_AUTH_RESULT, byteArrayOf(BleConstants.AUTH_SUCCESS)) + BleTransportBridge.sendDeviceName() + startHeartbeat() + } else { + Log.w(TAG, "BLE Auth Failed! Token mismatch.") + sendNotification(BleConstants.CHAR_AUTH_RESULT, byteArrayOf(BleConstants.AUTH_FAILED)) + } + } else { + Log.w(TAG, "BLE Auth Failed! No symmetric key found for last connected device.") + sendNotification(BleConstants.CHAR_AUTH_RESULT, byteArrayOf(BleConstants.AUTH_FAILED)) + } + } + } + + private fun startHeartbeat() { + stopHeartbeat() + heartbeatJob = scope.launch { + while (isActive && isAuthenticated) { + delay(5000) + if (connectedDevices.isNotEmpty()) { + val level = com.sameerasw.airsync.utils.DeviceInfoUtil.getBatteryInfo(context).level + sendNotification(BleConstants.CHAR_BATTERY_LEVEL, byteArrayOf(level.toByte())) + } + } + } + } + + private fun stopHeartbeat() { + heartbeatJob?.cancel() + heartbeatJob = null + } + + private fun handleNotificationDismiss(id: String) { + Log.d(TAG, "Handling notification dismissal from BLE: $id") + NotificationDismissalUtil.dismissNotification(id) + } + + private fun handleMacBattery(value: ByteArray) { + if (!isAuthenticated) return + val payload = String(value, Charsets.UTF_8) + val parts = payload.split(BleConstants.DELIMITER) + if (parts.size >= 2) { + val level = parts[0].toIntOrNull() ?: -1 + val isCharging = parts[1] == "1" + Log.d(TAG, "Received Mac battery level via BLE: $level%, charging: $isCharging") + MacDeviceStatusManager.updateBatteryStatus(context, level, isCharging) + } else if (value.size == 1) { + // Legacy 1-byte fallback + val level = value[0].toInt() and 0xFF + MacDeviceStatusManager.updateBatteryStatus(context, level, false) + } + } + + private fun handleMacMediaState(payload: String) { + if (!isAuthenticated) return + val parts = payload.split(BleConstants.DELIMITER) + if (parts.size >= 6) { + val isPlaying = parts[0] == "1" + val title = parts[1] + val artist = parts[2] + val volume = parts[3].toIntOrNull() ?: 0 + val isMuted = parts[4] == "1" + val likeStatus = parts[5] + val albumArt = if (parts.size >= 7) parts[6] else null + + Log.d(TAG, "Received Mac media state via BLE: $title by $artist (Playing: $isPlaying)") + MacDeviceStatusManager.updateMusicStatus( + context, isPlaying, title, artist, volume, isMuted, likeStatus, albumArt + ) + } + } + + private fun handleNotificationAction(value: ByteArray) { + if (!isAuthenticated) return + val data = String(value, Charsets.UTF_8) + BleTransportBridge.handleNotificationAction(data, context) + } + + private fun handleMediaControl(value: ByteArray) { + if (!isAuthenticated) return + val action = String(value, Charsets.UTF_8) + BleTransportBridge.handleMediaControl(action, context) + } + + /** + * Send a notification to all connected devices (with chunking) + */ + fun sendNotification(characteristicUuid: UUID, data: ByteArray) { + if (connectedDevices.isEmpty()) return + + // Characteristic level queue to ensure order + val queue = characteristicQueues.getOrPut(characteristicUuid) { ConcurrentLinkedQueue() } + queue.add(data) + + if (isSending[characteristicUuid] != true) { + processNextInQueue(characteristicUuid) + } + } + + fun sendChunkedNotification(characteristicUuid: UUID, payload: String) { + if (connectedDevices.isEmpty()) return + + // Truncate notification text to conserve BLE bandwidth + val truncatedPayload = if (characteristicUuid == BleConstants.CHAR_NOTIFICATION_DATA) { + payload.take(500) + } else payload + + val mtu = negotiatedMtu + val chunks = BleChunkUtil.splitIntoChunks(truncatedPayload, mtu) + + val queue = characteristicQueues.getOrPut(characteristicUuid) { ConcurrentLinkedQueue() } + chunks.forEach { queue.add(it) } + + if (isSending[characteristicUuid] != true) { + processNextInQueue(characteristicUuid) + } + } + + private fun processNextInQueues() { + characteristicQueues.keys.forEach { uuid -> + if (isSending[uuid] == true) { + isSending[uuid] = false + processNextInQueue(uuid) + } + } + } + + private fun processNextInQueue(uuid: UUID) { + val queue = characteristicQueues[uuid] ?: return + val data = queue.poll() ?: return + + isSending[uuid] = true + + val characteristic = findCharacteristic(uuid) ?: return + characteristic.value = data + + connectedDevices.forEach { device -> + gattServer?.notifyCharacteristicChanged(device, characteristic, false) + } + } + + private fun findCharacteristic(uuid: UUID): BluetoothGattCharacteristic? { + gattServer?.services?.forEach { service -> + service.getCharacteristic(uuid)?.let { return it } + } + return null + } + + private fun createReadCharacteristic(uuid: UUID): BluetoothGattCharacteristic { + return BluetoothGattCharacteristic(uuid, BluetoothGattCharacteristic.PROPERTY_READ, BluetoothGattCharacteristic.PERMISSION_READ) + } + + private fun createWriteCharacteristic(uuid: UUID): BluetoothGattCharacteristic { + return BluetoothGattCharacteristic(uuid, + BluetoothGattCharacteristic.PROPERTY_WRITE or BluetoothGattCharacteristic.PROPERTY_WRITE_NO_RESPONSE, + BluetoothGattCharacteristic.PERMISSION_WRITE) + } + + private fun createNotifyCharacteristic(uuid: UUID): BluetoothGattCharacteristic { + val char = BluetoothGattCharacteristic(uuid, BluetoothGattCharacteristic.PROPERTY_NOTIFY, BluetoothGattCharacteristic.PERMISSION_READ) + // Add CCCD for notification support + val configDescriptor = BluetoothGattDescriptor(UUID.fromString("00002902-0000-1000-8000-00805f9b34fb"), BluetoothGattDescriptor.PERMISSION_READ or BluetoothGattDescriptor.PERMISSION_WRITE) + char.addDescriptor(configDescriptor) + return char + } + + fun disconnectAllConnectedDevices() { + Log.d(TAG, "Disconnecting all connected BLE devices manually...") + val devicesCopy = synchronized(connectedDevices) { connectedDevices.toList() } + for (device in devicesCopy) { + try { + gattServer?.cancelConnection(device) + } catch (e: Exception) { + Log.w(TAG, "Failed to cancel connection for ${device.address}: ${e.message}") + } + } + isAuthenticated = false + _connectionState.value = BleConnectionState.DISCONNECTED + } +} diff --git a/app/src/main/java/com/sameerasw/airsync/data/ble/BleTransportBridge.kt b/app/src/main/java/com/sameerasw/airsync/data/ble/BleTransportBridge.kt new file mode 100644 index 00000000..07fe68f3 --- /dev/null +++ b/app/src/main/java/com/sameerasw/airsync/data/ble/BleTransportBridge.kt @@ -0,0 +1,154 @@ +package com.sameerasw.airsync.data.ble + +import android.util.Log +import com.sameerasw.airsync.domain.model.BatteryInfo +import com.sameerasw.airsync.domain.model.AudioInfo +import java.security.MessageDigest +import java.util.* +import com.sameerasw.airsync.utils.CallControlUtil +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch + +object BleTransportBridge { + private const val TAG = "BleTransportBridge" + + private var gattServer: BleGattServer? = null + + fun initialize(server: BleGattServer) { + gattServer = server + } + + fun deriveAuthToken(symmetricKey: String): String { + return try { + val md = MessageDigest.getInstance("SHA-256") + val hash = md.digest(symmetricKey.toByteArray(Charsets.UTF_8)) + Base64.getEncoder().encodeToString(hash.copyOf(16)) + } catch (e: Exception) { + Log.e(TAG, "Error deriving auth token: ${e.message}") + "" + } + } + + // --- Outbound (Android -> Mac) --- + + fun sendNotification(pkg: String, appName: String, title: String, text: String) { + val payload = listOf(pkg, appName, title, text).joinToString(BleConstants.DELIMITER) + gattServer?.sendChunkedNotification(BleConstants.CHAR_NOTIFICATION_DATA, payload) + } + + fun sendBatteryStatus(battery: BatteryInfo) { + val level = battery.level.toByte() + gattServer?.sendNotification(BleConstants.CHAR_BATTERY_LEVEL, byteArrayOf(level)) + } + + fun sendMediaState(audio: AudioInfo) { + val payload = listOf( + if (audio.isPlaying) "1" else "0", + audio.title, + audio.artist, + audio.volume.toString(), + if (audio.isMuted) "1" else "0", + audio.likeStatus, + "" // Avoid sending heavy base64 art over BLE to conserve bandwidth + ).joinToString(BleConstants.DELIMITER) + + gattServer?.sendChunkedNotification(BleConstants.CHAR_MEDIA_STATE, payload) + } + + fun sendSystemState(isDnd: Boolean, isPowerSave: Boolean) { + val payload = listOf( + if (isDnd) "1" else "0", + if (isPowerSave) "1" else "0" + ).joinToString(BleConstants.DELIMITER) + + gattServer?.sendNotification(BleConstants.CHAR_SYSTEM_STATE, payload.toByteArray()) + } + + fun sendClipboard(text: String) { + gattServer?.sendChunkedNotification(BleConstants.CHAR_CLIPBOARD_DATA_NOTIFY, text) + } + + fun sendNotificationDismissal(id: String) { + gattServer?.sendChunkedNotification(BleConstants.CHAR_NOTIFICATION_DISMISS_NOTIFY, id) + } + + fun sendDeviceName() { + val name = android.os.Build.MODEL + gattServer?.sendChunkedNotification(BleConstants.CHAR_DEVICE_NAME, name) + } + + // --- Inbound (Mac -> Android) --- + + fun handleMediaControl(action: String, context: android.content.Context) { + Log.d(TAG, "Media control from BLE: $action") + when { + action == "playPause" -> com.sameerasw.airsync.utils.MediaControlUtil.playPause(context) + action == "next" -> com.sameerasw.airsync.utils.MediaControlUtil.skipNext(context) + action == "previous" -> com.sameerasw.airsync.utils.MediaControlUtil.skipPrevious(context) + action == "callAccept" -> CallControlUtil.acceptCall(context) + action == "callDecline" || action == "callEnd" -> CallControlUtil.endCall(context) + + // Volume Controls over BLE + action == "volumeUp" -> com.sameerasw.airsync.utils.VolumeControlUtil.increaseVolume(context) + action == "volumeDown" -> com.sameerasw.airsync.utils.VolumeControlUtil.decreaseVolume(context) + action == "muteToggle" -> com.sameerasw.airsync.utils.VolumeControlUtil.toggleMute(context) + action.startsWith("setVolume|") -> { + val volStr = action.substringAfter("setVolume|") + val vol = volStr.toIntOrNull() + if (vol != null && vol in 0..100) { + com.sameerasw.airsync.utils.VolumeControlUtil.setVolume(context, vol) + } + } + action.startsWith("toggleNotif|") -> { + val parts = action.split("|") + if (parts.size >= 3) { + val pkg = parts[1] + val state = parts[2] == "true" + Log.d(TAG, "Received toggleAppNotif via BLE: pkg=$pkg, state=$state") + kotlinx.coroutines.CoroutineScope(kotlinx.coroutines.Dispatchers.IO).launch { + try { + val dataStoreManager = com.sameerasw.airsync.data.local.DataStoreManager.getInstance(context) + val currentApps = dataStoreManager.getNotificationApps().first().toMutableList() + val idx = currentApps.indexOfFirst { it.packageName == pkg } + if (idx != -1) { + currentApps[idx] = currentApps[idx].copy(isEnabled = state, lastUpdated = System.currentTimeMillis()) + dataStoreManager.saveNotificationApps(currentApps) + Log.d(TAG, "Successfully toggled app notification preference via BLE for $pkg to $state") + } else { + val isSystemApp = try { + val applicationInfo = context.packageManager.getApplicationInfo(pkg, 0) + (applicationInfo.flags and android.content.pm.ApplicationInfo.FLAG_SYSTEM) != 0 + } catch (_: Exception) { + false + } + val newApp = com.sameerasw.airsync.domain.model.NotificationApp( + packageName = pkg, + appName = pkg, + isEnabled = state, + isSystemApp = isSystemApp, + lastUpdated = System.currentTimeMillis() + ) + currentApps.add(newApp) + dataStoreManager.saveNotificationApps(currentApps) + Log.d(TAG, "Saved new app notification preference via BLE for $pkg to $state") + } + com.sameerasw.airsync.utils.SyncManager.checkAndSyncDeviceStatus(context, forceSync = true) + } catch (e: Exception) { + Log.e(TAG, "Error toggling app notification preference via BLE: ${e.message}") + } + } + } + } + } + } + + fun handleNotificationAction(data: String, context: android.content.Context) { + Log.d(TAG, "Notification action from BLE: $data") + val parts = data.split(BleConstants.DELIMITER) + if (parts.size >= 2) { + val id = parts[0] + val actionName = parts[1] + com.sameerasw.airsync.utils.NotificationDismissalUtil.performNotificationAction(id, actionName) + } + } +} 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 0fd57735..a7aad644 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 @@ -92,12 +92,16 @@ class DataStoreManager(private val context: Context) { private val PITCH_BLACK_THEME = booleanPreferencesKey("pitch_black_theme") private val SENTRY_REPORTING_ENABLED = booleanPreferencesKey("sentry_reporting_enabled") private val QUICK_SHARE_ENABLED = booleanPreferencesKey("quick_share_enabled") + private val FILE_ACCESS_ENABLED = booleanPreferencesKey("file_access_enabled") // Widget preferences private val WIDGET_TRANSPARENCY = androidx.datastore.preferences.core.floatPreferencesKey("widget_transparency") private val REMOTE_FLIPPED = booleanPreferencesKey("remote_flipped") + private val BLE_SYNC_ENABLED = booleanPreferencesKey("ble_sync_enabled") + private val BLE_AUTO_CONNECT_ENABLED = booleanPreferencesKey("ble_auto_connect_enabled") + private const val NETWORK_DEVICES_PREFIX = "network_device_" private const val NETWORK_CONNECTIONS_PREFIX = "network_connections_" @@ -343,6 +347,18 @@ class DataStoreManager(private val context: Context) { } } + suspend fun setFileAccessEnabled(enabled: Boolean) { + context.dataStore.edit { preferences -> + preferences[FILE_ACCESS_ENABLED] = enabled + } + } + + fun isFileAccessEnabled(): Flow { + return context.dataStore.data.map { preferences -> + preferences[FILE_ACCESS_ENABLED] != false // Default to enabled + } + } + suspend fun setDefaultTab(tab: String) { context.dataStore.edit { prefs -> prefs[DEFAULT_TAB] = tab @@ -1010,4 +1026,16 @@ class DataStoreManager(private val context: Context) { prefs[ESSENTIALS_CONNECTION_ENABLED] ?: false } } + + suspend fun setBleSyncEnabled(enabled: Boolean) { + context.dataStore.edit { it[BLE_SYNC_ENABLED] = enabled } + } + + fun getBleSyncEnabled(): Flow = context.dataStore.data.map { it[BLE_SYNC_ENABLED] ?: false } + + suspend fun setBleAutoConnectEnabled(enabled: Boolean) { + context.dataStore.edit { it[BLE_AUTO_CONNECT_ENABLED] = enabled } + } + + fun getBleAutoConnectEnabled(): Flow = context.dataStore.data.map { it[BLE_AUTO_CONNECT_ENABLED] ?: true } } diff --git a/app/src/main/java/com/sameerasw/airsync/data/repository/AirSyncRepositoryImpl.kt b/app/src/main/java/com/sameerasw/airsync/data/repository/AirSyncRepositoryImpl.kt index 02c5ee52..397217b6 100644 --- a/app/src/main/java/com/sameerasw/airsync/data/repository/AirSyncRepositoryImpl.kt +++ b/app/src/main/java/com/sameerasw/airsync/data/repository/AirSyncRepositoryImpl.kt @@ -295,4 +295,12 @@ class AirSyncRepositoryImpl( override fun isQuickShareEnabled(): Flow { return dataStoreManager.isQuickShareEnabled() } + + override suspend fun setFileAccessEnabled(enabled: Boolean) { + dataStoreManager.setFileAccessEnabled(enabled) + } + + override fun isFileAccessEnabled(): Flow { + return dataStoreManager.isFileAccessEnabled() + } } \ No newline at end of file diff --git a/app/src/main/java/com/sameerasw/airsync/domain/model/DeviceStatus.kt b/app/src/main/java/com/sameerasw/airsync/domain/model/DeviceStatus.kt index 22ad0224..5e6efe6a 100644 --- a/app/src/main/java/com/sameerasw/airsync/domain/model/DeviceStatus.kt +++ b/app/src/main/java/com/sameerasw/airsync/domain/model/DeviceStatus.kt @@ -12,6 +12,11 @@ data class AudioInfo( val volume: Int, val isMuted: Boolean, val albumArt: String? = null, + val albumArtLite: String? = null, + val durationMs: Long = 0L, + val positionMs: Long = 0L, + val positionTimestampMs: Long = 0L, + val isBuffering: Boolean = false, // New: like status for current media ("liked", "not_liked", or "none") val likeStatus: String = "none" ) @@ -21,6 +26,11 @@ data class MediaInfo( val title: String, val artist: String, val albumArt: String? = null, + val albumArtLite: String? = null, + val durationMs: Long = 0L, + val positionMs: Long = 0L, + val positionTimestampMs: Long = 0L, + val isBuffering: Boolean = false, // New: like status for current media ("liked", "not_liked", or "none") val likeStatus: String = "none" -) \ No newline at end of file +) diff --git a/app/src/main/java/com/sameerasw/airsync/domain/model/MacDeviceStatus.kt b/app/src/main/java/com/sameerasw/airsync/domain/model/MacDeviceStatus.kt index 7a22fcab..312f704c 100644 --- a/app/src/main/java/com/sameerasw/airsync/domain/model/MacDeviceStatus.kt +++ b/app/src/main/java/com/sameerasw/airsync/domain/model/MacDeviceStatus.kt @@ -1,6 +1,7 @@ package com.sameerasw.airsync.domain.model data class MacDeviceStatus( + val name: String = "Unknown", val battery: MacBattery, val isPaired: Boolean, val music: MacMusicInfo @@ -18,5 +19,9 @@ data class MacMusicInfo( val volume: Int, val isMuted: Boolean, val albumArt: String, - val likeStatus: String + val likeStatus: String, + val elapsedTime: Long = 0L, + val duration: Long = 0L, + val timestamp: String? = null, + val playbackRate: Double = 1.0 ) 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 79c93973..44bd392e 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 @@ -49,5 +49,7 @@ data class UiState( val isSentryReportingEnabled: Boolean = true, val isOnboardingCompleted: Boolean = true, val widgetTransparency: Float = 1f, - val isQuickShareEnabled: Boolean = false + val isQuickShareEnabled: Boolean = false, + val isFileAccessEnabled: Boolean = true, + val bleConnectionState: com.sameerasw.airsync.data.ble.BleGattServer.BleConnectionState = com.sameerasw.airsync.data.ble.BleGattServer.BleConnectionState.DISCONNECTED ) \ No newline at end of file diff --git a/app/src/main/java/com/sameerasw/airsync/domain/repository/AirSyncRepository.kt b/app/src/main/java/com/sameerasw/airsync/domain/repository/AirSyncRepository.kt index 32f2defd..ab1420d3 100644 --- a/app/src/main/java/com/sameerasw/airsync/domain/repository/AirSyncRepository.kt +++ b/app/src/main/java/com/sameerasw/airsync/domain/repository/AirSyncRepository.kt @@ -132,4 +132,8 @@ interface AirSyncRepository { // Quick Share (receiving) suspend fun setQuickShareEnabled(enabled: Boolean) fun isQuickShareEnabled(): Flow + + // File Access (WebDAV Server) + suspend fun setFileAccessEnabled(enabled: Boolean) + fun isFileAccessEnabled(): Flow } \ No newline at end of file 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 97d0940c..3e0ad33e 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 @@ -51,6 +51,18 @@ class PermissionsActivity : ComponentActivity() { ActivityResultContracts.RequestPermission() ) { refreshUI() } + private val bluetoothPermissionLauncher = registerForActivityResult( + ActivityResultContracts.RequestMultiplePermissions() + ) { refreshUI() } + + private val localNetworkPermissionLauncher = registerForActivityResult( + ActivityResultContracts.RequestPermission() + ) { refreshUI() } + + private val answerCallsPermissionLauncher = registerForActivityResult( + ActivityResultContracts.RequestPermission() + ) { refreshUI() } + private var refreshCounter by mutableStateOf(0) @OptIn(ExperimentalMaterial3Api::class) @@ -120,6 +132,15 @@ class PermissionsActivity : ComponentActivity() { onRequestPhonePermission = { requestPhonePermission() }, + onRequestBluetoothPermission = { + requestBluetoothPermission() + }, + onRequestLocalNetworkPermission = { + requestLocalNetworkPermission() + }, + onRequestAnswerCallsPermission = { + requestAnswerCallsPermission() + }, refreshTrigger = refreshCounter ) } @@ -156,6 +177,36 @@ class PermissionsActivity : ComponentActivity() { phonePermissionLauncher.launch(Manifest.permission.READ_PHONE_STATE) } } + + private fun requestBluetoothPermission() { + if (!PermissionUtil.isBluetoothPermissionsGranted(this)) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + bluetoothPermissionLauncher.launch( + arrayOf( + Manifest.permission.BLUETOOTH_SCAN, + Manifest.permission.BLUETOOTH_CONNECT, + Manifest.permission.BLUETOOTH_ADVERTISE + ) + ) + } + } + } + + private fun requestLocalNetworkPermission() { + if (Build.VERSION.SDK_INT >= 37) { + if (!PermissionUtil.isLocalNetworkPermissionGranted(this)) { + localNetworkPermissionLauncher.launch("android.permission.ACCESS_LOCAL_NETWORK") + } + } + } + + private fun requestAnswerCallsPermission() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + if (!PermissionUtil.isAnswerCallsPermissionGranted(this)) { + answerCallsPermissionLauncher.launch(Manifest.permission.ANSWER_PHONE_CALLS) + } + } + } override fun onResume() { super.onResume() diff --git a/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/FloatingMediaPlayer.kt b/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/FloatingMediaPlayer.kt index 8f390c44..91ccfc2d 100644 --- a/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/FloatingMediaPlayer.kt +++ b/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/FloatingMediaPlayer.kt @@ -34,6 +34,7 @@ import androidx.compose.material3.ButtonGroup import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.LinearWavyProgressIndicator import androidx.compose.material3.FilledIconButton import androidx.compose.material3.FilledTonalIconButton import androidx.compose.material3.Icon @@ -59,6 +60,7 @@ import androidx.compose.animation.core.exponentialDecay import androidx.compose.animation.core.spring import androidx.compose.animation.core.tween import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.basicMarquee import androidx.compose.foundation.gestures.AnchoredDraggableState import androidx.compose.foundation.gestures.DraggableAnchors import androidx.compose.foundation.gestures.Orientation @@ -101,6 +103,21 @@ fun FloatingMediaPlayer( val scope = rememberCoroutineScope() val haptics = LocalHapticFeedback.current + var currentElapsedTimeMs by remember(musicInfo) { mutableStateOf(musicInfo?.elapsedTime ?: 0L) } + + LaunchedEffect(musicInfo?.isPlaying, musicInfo?.elapsedTime) { + if (musicInfo?.isPlaying == true) { + var lastTime = System.currentTimeMillis() + while (true) { + kotlinx.coroutines.delay(500) + val now = System.currentTimeMillis() + val delta = (now - lastTime) * ((musicInfo.playbackRate).toFloat()) + currentElapsedTimeMs += delta.toLong() + lastTime = now + } + } + } + val collapsedHeight = 72.dp val expandedHeight = 280.dp @@ -199,7 +216,7 @@ fun FloatingMediaPlayer( style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold, maxLines = 1, - overflow = TextOverflow.Ellipsis, + modifier = Modifier.basicMarquee(), color = MaterialTheme.colorScheme.onSurface ) Text( @@ -207,7 +224,7 @@ fun FloatingMediaPlayer( style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, - overflow = TextOverflow.Ellipsis + modifier = Modifier.basicMarquee(), ) } @@ -273,6 +290,44 @@ fun FloatingMediaPlayer( Spacer(modifier = Modifier.size(48.dp)) // To balance the chevron } + if (musicInfo != null && musicInfo.duration > 0) { + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + val durationSeconds = musicInfo.duration / 1000L + val elapsedSeconds = (currentElapsedTimeMs / 1000L).coerceIn(0L, durationSeconds) + val elapsedFraction = (currentElapsedTimeMs.toFloat() / musicInfo.duration.toFloat()).coerceIn(0f, 1f) + + LinearWavyProgressIndicator( + progress = { elapsedFraction }, + modifier = Modifier + .fillMaxWidth() + .height(10.dp), + color = MaterialTheme.colorScheme.primary, + trackColor = MaterialTheme.colorScheme.primaryContainer, + wavelength = 20.dp, + amplitude = { if (musicInfo.isPlaying) 1.0f else 0f } + ) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text( + text = formatTime(elapsedSeconds), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Text( + text = formatTime(durationSeconds), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } + // Media Controls Row( modifier = Modifier.fillMaxWidth(), @@ -350,3 +405,14 @@ fun FloatingMediaPlayer( fun lerp(start: Float, stop: Float, fraction: Float): Float { return (1 - fraction) * start + fraction * stop } + +private fun formatTime(seconds: Long): String { + val hours = seconds / 3600 + val mins = (seconds % 3600) / 60 + val secs = seconds % 60 + return if (hours > 0) { + "$hours:${if (mins < 10) "0" else ""}$mins:${if (secs < 10) "0" else ""}$secs" + } else { + "$mins:${if (secs < 10) "0" else ""}$secs" + } +} diff --git a/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/RoundedCardContainer.kt b/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/RoundedCardContainer.kt index 01f41696..d9855a03 100644 --- a/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/RoundedCardContainer.kt +++ b/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/RoundedCardContainer.kt @@ -1,12 +1,15 @@ package com.sameerasw.airsync.presentation.ui.components +import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp @@ -17,6 +20,7 @@ import androidx.compose.ui.unit.dp * @param modifier Modifier to apply to the container * @param spacing Vertical spacing between child cards (default: 2.dp) * @param cornerRadius Corner radius for the entire container (default: 24.dp) + * @param containerColor Background color for the container * @param content The content to be placed inside the container */ @Composable @@ -24,13 +28,16 @@ fun RoundedCardContainer( modifier: Modifier = Modifier, spacing: Dp = 2.dp, cornerRadius: Dp = 24.dp, + containerColor: Color = Color.Transparent, content: @Composable ColumnScope.() -> Unit ) { Column( modifier = modifier - .clip(RoundedCornerShape(cornerRadius)), + .clip(RoundedCornerShape(cornerRadius)) + .background(containerColor), verticalArrangement = Arrangement.spacedBy(spacing), content = content ) } + 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 993a4add..76a7db2d 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 @@ -29,14 +29,24 @@ import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.Icon +import androidx.compose.material3.ListItem +import androidx.compose.material3.ListItemDefaults import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable +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 com.sameerasw.airsync.presentation.ui.components.sheets.AppSelectionSheet import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp +import com.sameerasw.airsync.R import com.sameerasw.airsync.domain.model.DeviceInfo import com.sameerasw.airsync.domain.model.UiState import com.sameerasw.airsync.presentation.ui.components.cards.ClipboardFeaturesCard @@ -48,7 +58,7 @@ import com.sameerasw.airsync.presentation.ui.components.cards.MediaSyncCard import com.sameerasw.airsync.presentation.ui.components.cards.NotificationSyncCard import com.sameerasw.airsync.presentation.ui.components.cards.PermissionsCard import com.sameerasw.airsync.presentation.ui.components.cards.QuickSettingsTilesCard -import com.sameerasw.airsync.presentation.ui.components.cards.SendNowPlayingCard +import com.sameerasw.airsync.presentation.ui.components.cards.IconToggleItem import com.sameerasw.airsync.presentation.ui.components.cards.SmartspacerCard import com.sameerasw.airsync.presentation.viewmodel.AirSyncViewModel import com.sameerasw.airsync.utils.HapticUtil @@ -92,6 +102,7 @@ fun SettingsView( onToggleDeveloperMode: () -> Unit = {} ) { val haptics = LocalHapticFeedback.current + var showAppSelectionSheet by remember { mutableStateOf(false) } Column( modifier = modifier @@ -116,46 +127,16 @@ fun SettingsView( PermissionsCard(missingPermissionsCount = uiState.missingPermissions.size) // Help and guides card - Card( - modifier = Modifier - .fillMaxWidth() - .clickable { - HapticUtil.performClick(haptics) - onShowHelp() - }, - shape = MaterialTheme.shapes.extraSmall, - colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surfaceContainerHighest - ) - ) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(16.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - Column(modifier = Modifier.weight(1f)) { - Text( - androidx.compose.ui.res.stringResource(com.sameerasw.airsync.R.string.label_help_guides), - style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.onSurface - ) - Text( - androidx.compose.ui.res.stringResource(com.sameerasw.airsync.R.string.subtitle_help_guides), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f) - ) - } - - Icon( - painter = androidx.compose.ui.res.painterResource(id = com.sameerasw.airsync.R.drawable.rounded_keyboard_arrow_right_24), - contentDescription = "Open help", - modifier = Modifier.size(24.dp), - tint = MaterialTheme.colorScheme.onSurface - ) + com.sameerasw.airsync.presentation.ui.components.cards.IconToggleItem( + iconRes = com.sameerasw.airsync.R.drawable.rounded_info_24, + title = androidx.compose.ui.res.stringResource(com.sameerasw.airsync.R.string.label_help_guides), + description = androidx.compose.ui.res.stringResource(com.sameerasw.airsync.R.string.subtitle_help_guides), + showToggle = false, + onClick = { + HapticUtil.performClick(haptics) + onShowHelp() } - } + ) QuickSettingsTilesCard( isConnectionTileAdded = com.sameerasw.airsync.utils.QuickSettingsUtil.isQSTileAdded( @@ -165,10 +146,6 @@ fun SettingsView( isClipboardTileAdded = com.sameerasw.airsync.utils.QuickSettingsUtil.isQSTileAdded( context, com.sameerasw.airsync.service.ClipboardTileService::class.java - ), - isQuickShareTileAdded = com.sameerasw.airsync.utils.QuickSettingsUtil.isQSTileAdded( - context, - ) ) } @@ -182,13 +159,9 @@ fun SettingsView( onDefaultTabChange = { tab -> viewModel.setDefaultTab(tab) } ) - SendNowPlayingCard( - isSendNowPlayingEnabled = uiState.isBlurSettingEnabled, - onToggleSendNowPlaying = { enabled: Boolean -> - viewModel.setUseBlurEnabled(enabled, context) - }, + IconToggleItem( title = androidx.compose.ui.res.stringResource(com.sameerasw.airsync.R.string.label_use_blur), - subtitle = when { + description = when { com.sameerasw.airsync.utils.DeviceInfoUtil.isBlurProblematicDevice() -> androidx.compose.ui.res.stringResource(com.sameerasw.airsync.R.string.subtitle_blur_disabled_samsung) @@ -197,25 +170,32 @@ fun SettingsView( else -> androidx.compose.ui.res.stringResource(com.sameerasw.airsync.R.string.subtitle_use_blur) }, + iconRes = R.drawable.rounded_blur_on_24, + isChecked = uiState.isBlurSettingEnabled, + onCheckedChange = { enabled: Boolean -> + viewModel.setUseBlurEnabled(enabled, context) + }, enabled = !com.sameerasw.airsync.utils.DeviceInfoUtil.isBlurProblematicDevice() ) - SendNowPlayingCard( - isSendNowPlayingEnabled = uiState.isPitchBlackThemeEnabled, - onToggleSendNowPlaying = { enabled: Boolean -> - viewModel.setPitchBlackThemeEnabled(enabled) - }, + IconToggleItem( title = androidx.compose.ui.res.stringResource(com.sameerasw.airsync.R.string.label_pitch_black_theme), - subtitle = androidx.compose.ui.res.stringResource(com.sameerasw.airsync.R.string.subtitle_pitch_black_theme) + description = androidx.compose.ui.res.stringResource(com.sameerasw.airsync.R.string.subtitle_pitch_black_theme), + iconRes = R.drawable.rounded_dark_mode_24, + isChecked = uiState.isPitchBlackThemeEnabled, + onCheckedChange = { enabled: Boolean -> + viewModel.setPitchBlackThemeEnabled(enabled) + } ) - SendNowPlayingCard( - isSendNowPlayingEnabled = uiState.isSentryReportingEnabled, - onToggleSendNowPlaying = { enabled: Boolean -> - viewModel.setSentryReportingEnabled(enabled) - }, + IconToggleItem( title = androidx.compose.ui.res.stringResource(com.sameerasw.airsync.R.string.label_error_reporting), - subtitle = androidx.compose.ui.res.stringResource(com.sameerasw.airsync.R.string.subtitle_error_reporting) + description = androidx.compose.ui.res.stringResource(com.sameerasw.airsync.R.string.subtitle_error_reporting), + iconRes = R.drawable.rounded_bug_report_24, + isChecked = uiState.isSentryReportingEnabled, + onCheckedChange = { enabled: Boolean -> + viewModel.setSentryReportingEnabled(enabled) + } ) } } @@ -233,6 +213,20 @@ fun SettingsView( onGrantPermissions = { viewModel.setPermissionDialogVisible(true) } ) + if (uiState.isNotificationSyncEnabled && uiState.isNotificationEnabled) { + IconToggleItem( + title = stringResource(R.string.action_select_apps), + description = stringResource(R.string.subtitle_to_be_notified), + iconRes = R.drawable.rounded_notification_settings_24, + showToggle = false, + onClick = { + HapticUtil.performClick(haptics) + viewModel.loadNotificationApps(context) + showAppSelectionSheet = true + } + ) + } + ClipboardFeaturesCard( isClipboardSyncEnabled = uiState.isClipboardSyncEnabled, onToggleClipboardSync = { enabled: Boolean -> @@ -261,17 +255,29 @@ fun SettingsView( } ) - SendNowPlayingCard( - isSendNowPlayingEnabled = uiState.isQuickShareEnabled, - onToggleSendNowPlaying = { enabled: Boolean -> - viewModel.setQuickShareEnabled(context, enabled) - }, + IconToggleItem( title = "Quick Share", - subtitle = "Allow receiving files from nearby devices" + description = "Allow receiving files from nearby devices", + iconRes = R.drawable.quick_share, + isChecked = uiState.isQuickShareEnabled, + onCheckedChange = { enabled: Boolean -> + viewModel.setQuickShareEnabled(context, enabled) + } + ) + + IconToggleItem( + title = stringResource(R.string.label_file_access), + description = stringResource(R.string.subtitle_file_access), + iconRes = R.drawable.rounded_folder_managed_24, + isChecked = uiState.isFileAccessEnabled, + onCheckedChange = { enabled: Boolean -> + viewModel.setFileAccessEnabled(context, enabled) + } ) } } + // Integration Section Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { SettingsCategoryTitle("Integration") @@ -291,51 +297,45 @@ fun SettingsView( } if (isEssentialsInstalled) { - SendNowPlayingCard( - isSendNowPlayingEnabled = uiState.isEssentialsConnectionEnabled, - onToggleSendNowPlaying = { enabled: Boolean -> - viewModel.setEssentialsConnectionEnabled(enabled) - }, + IconToggleItem( title = androidx.compose.ui.res.stringResource(com.sameerasw.airsync.R.string.connect_to_essentials), - subtitle = androidx.compose.ui.res.stringResource(com.sameerasw.airsync.R.string.connect_to_essentials_summary) + description = androidx.compose.ui.res.stringResource(com.sameerasw.airsync.R.string.connect_to_essentials_summary), + iconRes = R.drawable.essentials_icon, + isChecked = uiState.isEssentialsConnectionEnabled, + onCheckedChange = { enabled: Boolean -> + viewModel.setEssentialsConnectionEnabled(enabled) + } ) } else { - Card( - modifier = Modifier.fillMaxWidth(), - shape = MaterialTheme.shapes.extraSmall, - colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surfaceContainerHighest - ) - ) { - androidx.compose.material3.ListItem( - colors = androidx.compose.material3.ListItemDefaults.colors( - containerColor = androidx.compose.ui.graphics.Color.Transparent - ), - headlineContent = { Text(androidx.compose.ui.res.stringResource(com.sameerasw.airsync.R.string.download_essentials)) }, - supportingContent = { - Text( - androidx.compose.ui.res.stringResource( - com.sameerasw.airsync.R.string.download_essentials_summary - ) + ListItem( + colors = ListItemDefaults.colors( + containerColor = androidx.compose.ui.graphics.Color.Transparent + ), + headlineContent = { Text(androidx.compose.ui.res.stringResource(com.sameerasw.airsync.R.string.download_essentials)) }, + supportingContent = { + Text( + androidx.compose.ui.res.stringResource( + com.sameerasw.airsync.R.string.download_essentials_summary ) - }, - trailingContent = { - Button( - onClick = { - val intent = android.content.Intent( - android.content.Intent.ACTION_VIEW, - android.net.Uri.parse("https://github.com/sameerasw/essentials/releases/latest") - ) - intent.flags = - android.content.Intent.FLAG_ACTIVITY_NEW_TASK - context.startActivity(intent) - } - ) { - Text("Download") + ) + }, + trailingContent = { + Button( + onClick = { + HapticUtil.performClick(haptics) + val intent = android.content.Intent( + android.content.Intent.ACTION_VIEW, + android.net.Uri.parse("https://github.com/sameerasw/essentials/releases/latest") + ) + intent.flags = + android.content.Intent.FLAG_ACTIVITY_NEW_TASK + context.startActivity(intent) } + ) { + Text("Download") } - ) - } + } + ) } } } @@ -473,6 +473,21 @@ fun SettingsView( Spacer(modifier = Modifier.height(180.dp)) } + + if (showAppSelectionSheet) { + val apps by viewModel.notificationApps.collectAsState() + AppSelectionSheet( + onDismissRequest = { showAppSelectionSheet = false }, + apps = apps, + onAppToggle = { pkg, enabled -> + viewModel.toggleNotificationApp(context, pkg, enabled) + }, + onSaveAll = { updatedList -> + viewModel.saveAllNotificationApps(context, updatedList) + }, + isLoading = apps.isEmpty() + ) + } } @Composable diff --git a/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/cards/BleSyncCard.kt b/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/cards/BleSyncCard.kt new file mode 100644 index 00000000..045fb0f1 --- /dev/null +++ b/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/cards/BleSyncCard.kt @@ -0,0 +1,58 @@ +package com.sameerasw.airsync.presentation.ui.components.cards + +import androidx.compose.foundation.layout.* +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Bluetooth +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.dp +import com.sameerasw.airsync.data.local.DataStoreManager +import com.sameerasw.airsync.data.ble.BleGattServer +import kotlinx.coroutines.launch + +import com.sameerasw.airsync.presentation.ui.components.RoundedCardContainer +import com.sameerasw.airsync.presentation.ui.components.cards.IconToggleItem +import com.sameerasw.airsync.R + +@Composable +fun BleSyncCard(viewModel: com.sameerasw.airsync.presentation.viewmodel.AirSyncViewModel) { + val context = LocalContext.current + val dataStoreManager = remember { DataStoreManager.getInstance(context) } + val scope = rememberCoroutineScope() + + val bleEnabled by dataStoreManager.getBleSyncEnabled().collectAsState(initial = false) + val autoConnect by dataStoreManager.getBleAutoConnectEnabled().collectAsState(initial = true) + + val uiState by viewModel.uiState.collectAsState() + val bleState = uiState.bleConnectionState + + val statusText = when (bleState) { + BleGattServer.BleConnectionState.DISCONNECTED -> "For nearby connection" + BleGattServer.BleConnectionState.ADVERTISING -> "Scanning" + BleGattServer.BleConnectionState.CONNECTED -> "Authenticating" + BleGattServer.BleConnectionState.AUTHENTICATED -> "Connected" + } + + IconToggleItem( + iconRes = R.drawable.rounded_bluetooth_24, + title = "Bluetooth LE Sync", + description = statusText, + isChecked = bleEnabled, + onCheckedChange = { + scope.launch { dataStoreManager.setBleSyncEnabled(it) } + } + ) + + IconToggleItem( + iconRes = R.drawable.rounded_bluetooth_searching_24, + title = "BLE Auto-connect", + description = "Automatically switch to Bluetooth when Wi-Fi drops", + isChecked = autoConnect, + onCheckedChange = { + scope.launch { dataStoreManager.setBleAutoConnectEnabled(it) } + }, + enabled = bleEnabled + ) +} 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 54f03e3b..eab5a685 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 @@ -2,16 +2,12 @@ package com.sameerasw.airsync.presentation.ui.components.cards import androidx.compose.animation.animateContentSize import androidx.compose.foundation.Image -import androidx.compose.foundation.background -import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.FlowRow -import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.defaultMinSize -import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding @@ -25,23 +21,16 @@ import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.Icon import androidx.compose.material3.LoadingIndicator import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedButton import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.graphics.Brush -import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.res.painterResource -import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp -import com.sameerasw.airsync.R import com.sameerasw.airsync.domain.model.ConnectedDevice import com.sameerasw.airsync.domain.model.UiState -import com.sameerasw.airsync.presentation.ui.components.RotatingAppIcon import com.sameerasw.airsync.presentation.ui.components.SlowlyRotatingAppIcon import com.sameerasw.airsync.utils.DevicePreviewResolver import com.sameerasw.airsync.utils.HapticUtil @@ -55,189 +44,160 @@ fun ConnectionStatusCard( connectedDevice: ConnectedDevice? = null, lastConnected: Boolean, uiState: UiState, + modifier: Modifier = Modifier ) { val haptics = androidx.compose.ui.platform.LocalHapticFeedback.current - // Determine gradient color - val gradientColor = when { - isConnected -> MaterialTheme.colorScheme.primary - isConnecting -> Color(0xFFFFC107) // Yellow - else -> Color(0xFFF44336) // Red - } - Card( - modifier = Modifier - .fillMaxWidth() - .defaultMinSize(minHeight = if (isConnected) 160.dp else 50.dp) - .animateContentSize(), + modifier = modifier.fillMaxWidth(), shape = MaterialTheme.shapes.extraSmall, + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceBright + ) ) { Column( modifier = Modifier - .fillMaxSize() -// .background( -// brush = Brush.linearGradient( -// colors = listOf( -// gradientColor.copy(alpha = 0.3f), -// Color.Transparent -// ), -// start = Offset(0f, 1f), -// end = Offset.Infinite -// ) -// ) + .fillMaxWidth() + .defaultMinSize(minHeight = if (isConnected) 160.dp else 50.dp) + .animateContentSize() .padding(horizontal = 16.dp, vertical = 12.dp), verticalArrangement = Arrangement.spacedBy(12.dp), horizontalAlignment = Alignment.CenterHorizontally ) { - // 1) Device image at the top (only when connected) - if (isConnected) { - val previewRes = DevicePreviewResolver.getPreviewRes(connectedDevice) - Image( - painter = painterResource(id = previewRes), - contentDescription = "Connected Mac preview", - modifier = Modifier - .fillMaxWidth(0.75f), - contentScale = ContentScale.Fit, - colorFilter = androidx.compose.ui.graphics.ColorFilter.tint(MaterialTheme.colorScheme.primary) + // 1) Device image at the top (only when connected) + if (isConnected) { + val previewRes = DevicePreviewResolver.getPreviewRes(connectedDevice) + Image( + painter = painterResource(id = previewRes), + contentDescription = "Connected Mac preview", + modifier = Modifier.fillMaxWidth(0.75f), + contentScale = ContentScale.Fit, + colorFilter = androidx.compose.ui.graphics.ColorFilter.tint(MaterialTheme.colorScheme.primary) + ) + } + + // 2) Device info block (when connected) + if (isConnected && connectedDevice != null) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + "${connectedDevice.name}", + style = MaterialTheme.typography.headlineSmall, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.weight(1f) ) - } - // 2) Device info block (when connected) - if (isConnected && connectedDevice != null) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically + Surface( + shape = RoundedCornerShape(8.dp), + color = if (connectedDevice.isPlus) + MaterialTheme.colorScheme.primaryContainer + else + MaterialTheme.colorScheme.surfaceVariant, + modifier = Modifier.padding(start = 16.dp) ) { Text( - "${connectedDevice.name}", - style = MaterialTheme.typography.headlineSmall, - color = MaterialTheme.colorScheme.onPrimaryContainer, - modifier = Modifier.weight(1f) + text = if (connectedDevice.isPlus) "PLUS" else "FREE", + modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp), + style = MaterialTheme.typography.labelSmall, + color = if (connectedDevice.isPlus) + MaterialTheme.colorScheme.onPrimaryContainer + else + MaterialTheme.colorScheme.onSurfaceVariant ) + } + } - Card( - colors = CardDefaults.cardColors( - containerColor = if (connectedDevice.isPlus) - MaterialTheme.colorScheme.primaryContainer - else - MaterialTheme.colorScheme.surfaceVariant - ), - modifier = Modifier.padding(start = 16.dp) + FlowRow( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + val ips = uiState.ipAddress.split(",").map { it.trim() }.filter { it.isNotEmpty() } + ips.forEach { ip -> + val isActive = ip == uiState.activeIp + Surface( + shape = RoundedCornerShape(12.dp), + color = if (isActive) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.surfaceVariant, + modifier = Modifier.animateContentSize() ) { Text( - text = if (connectedDevice.isPlus) "PLUS" else "FREE", - modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp), - style = MaterialTheme.typography.labelSmall, - color = if (connectedDevice.isPlus) - MaterialTheme.colorScheme.onPrimaryContainer - else - MaterialTheme.colorScheme.onSurfaceVariant + text = "$ip:${connectedDevice.port}", + modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp), + style = MaterialTheme.typography.labelMedium, + color = if (isActive) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onSurfaceVariant ) } } + } + } - FlowRow( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalArrangement = Arrangement.spacedBy(8.dp) - ) { - val ips = - uiState.ipAddress.split(",").map { it.trim() }.filter { it.isNotEmpty() } - ips.forEach { ip -> - val isActive = ip == uiState.activeIp - Surface( - shape = RoundedCornerShape(12.dp), - color = if (isActive) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.surfaceVariant, - modifier = Modifier.animateContentSize() - ) { - Text( - text = "$ip:${connectedDevice.port}", - modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp), - style = MaterialTheme.typography.labelMedium, - color = if (isActive) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onSurfaceVariant - ) - } - } - } + // 3) Connection status row last + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = if (isConnected) 0.dp else 8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + val statusText = when { + isConnecting -> "Connecting..." + isConnected -> "Syncing" + else -> "Disconnected" } - // 3) Connection status row last - Row( - modifier = Modifier - .fillMaxWidth() - .padding(vertical = if (isConnected) 0.dp else 8.dp), - verticalAlignment = Alignment.CenterVertically - ) { - val statusText = when { - isConnecting -> "Connecting..." - isConnected -> "Syncing" - else -> "Disconnected" - } + if (isConnecting) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { LoadingIndicator() } + } - if (isConnecting) { - Column(horizontalAlignment = Alignment.CenterHorizontally) { LoadingIndicator() } + if (isConnected) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + SlowlyRotatingAppIcon( + modifier = Modifier.size(54.dp) + ) } + } else if (!isConnecting) { + Icon( + painter = painterResource(id = com.sameerasw.airsync.R.drawable.rounded_devices_off_24), + contentDescription = "Disconnected", + modifier = Modifier.padding(end = 8.dp), + tint = MaterialTheme.colorScheme.error + ) + } - if (isConnected) { - Column(horizontalAlignment = Alignment.CenterHorizontally) { - SlowlyRotatingAppIcon( - modifier = Modifier - .size(54.dp) - ) - } -// Icon( -// painter = painterResource(id = com.sameerasw.airsync.R.drawable.rounded_devices_24), -// contentDescription = "Connected", -// modifier = Modifier.padding(end = 8.dp), -// tint = MaterialTheme.colorScheme.primary -// ) + Text( + text = statusText, + style = MaterialTheme.typography.bodyLarge, + modifier = Modifier.weight(1f) + ) - } else if (!isConnecting) { + if (isConnected) { + Button( + onClick = { + HapticUtil.performClick(haptics) + onDisconnect() + }, + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.surfaceBright, + contentColor = MaterialTheme.colorScheme.onSecondaryContainer + ), + modifier = Modifier.height(48.dp) + ) { Icon( painter = painterResource(id = com.sameerasw.airsync.R.drawable.rounded_devices_off_24), - contentDescription = "Disconnected", - modifier = Modifier.padding(end = 8.dp), - tint = MaterialTheme.colorScheme.error + contentDescription = null, + modifier = Modifier.size(18.dp) + ) + Spacer(modifier = Modifier.size(6.dp)) + Text( + text = "Disconnect", + style = MaterialTheme.typography.labelLarge, + maxLines = 1 ) - } - - Text( - text = statusText, - style = MaterialTheme.typography.bodyLarge, - modifier = Modifier.weight(1f) - ) - - if (isConnected) { - - Button( - onClick = { - HapticUtil.performClick(haptics) - onDisconnect() - }, - colors = ButtonDefaults.buttonColors( - containerColor = MaterialTheme.colorScheme.surfaceBright, - contentColor = MaterialTheme.colorScheme.onSecondaryContainer - ), - modifier = Modifier - .height(48.dp) - ) { - Icon( - painter = painterResource(id = com.sameerasw.airsync.R.drawable.rounded_devices_off_24), - contentDescription = null, - modifier = Modifier.size(18.dp) - ) - Spacer(modifier = Modifier.size(6.dp)) - Text( - text = "Disconnect", - style = MaterialTheme.typography.labelLarge, - maxLines = 1 - ) - } } } } } - - +} } \ No newline at end of file diff --git a/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/cards/DefaultTabCard.kt b/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/cards/DefaultTabCard.kt index 386b63fb..03f62102 100644 --- a/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/cards/DefaultTabCard.kt +++ b/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/cards/DefaultTabCard.kt @@ -11,7 +11,6 @@ import androidx.compose.foundation.layout.size import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.ContentPaste import androidx.compose.material.icons.filled.Gamepad -import androidx.compose.material.icons.filled.Phonelink import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi @@ -33,19 +32,22 @@ import com.sameerasw.airsync.utils.HapticUtil @Composable fun DefaultTabCard( currentDefaultTab: String, - onDefaultTabChange: (String) -> Unit + onDefaultTabChange: (String) -> Unit, + modifier: Modifier = Modifier ) { val haptics = LocalHapticFeedback.current Card( - modifier = Modifier.fillMaxWidth(), + modifier = modifier.fillMaxWidth(), shape = MaterialTheme.shapes.extraSmall, colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surfaceContainerHighest + containerColor = MaterialTheme.colorScheme.surfaceBright ) ) { Column( - modifier = Modifier.padding(16.dp) + modifier = Modifier + .fillMaxWidth() + .padding(16.dp) ) { Text( text = "Default tab", @@ -66,7 +68,7 @@ fun DefaultTabCard( ) { TabOption( title = "Connect", - iconRes = R.drawable.ic_launcher_monochrome, + iconRes = R.drawable.rounded_devices_24, isSelected = currentDefaultTab == "connect", onClick = { HapticUtil.performClick(haptics) @@ -105,6 +107,7 @@ fun DefaultTabCard( } } + @OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable private fun TabOption( diff --git a/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/cards/DeveloperModeCard.kt b/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/cards/DeveloperModeCard.kt index 36ae30ba..a7494d83 100644 --- a/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/cards/DeveloperModeCard.kt +++ b/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/cards/DeveloperModeCard.kt @@ -13,14 +13,11 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width -import androidx.compose.foundation.background 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.MaterialTheme -import androidx.compose.material3.Switch import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable @@ -28,6 +25,7 @@ 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.R import com.sameerasw.airsync.utils.HapticUtil @Composable @@ -41,203 +39,194 @@ fun DeveloperModeCard( onExportData: () -> Unit, onImportData: () -> Unit, onResetOnboarding: () -> Unit, - // Icon Sync Parameters isIconSyncLoading: Boolean, iconSyncMessage: String, onManualSyncIcons: () -> Unit, onClearIconSyncMessage: () -> Unit, - isConnected: Boolean + isConnected: Boolean, + modifier: Modifier = Modifier ) { val haptics = LocalHapticFeedback.current Card( - modifier = Modifier.fillMaxWidth(), + modifier = modifier.fillMaxWidth(), shape = MaterialTheme.shapes.extraSmall, colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surfaceContainerHighest + containerColor = MaterialTheme.colorScheme.surfaceBright ) ) { - Column(modifier = Modifier.padding(16.dp)) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - Text("Developer Mode", style = MaterialTheme.typography.titleMedium) - Switch( - checked = isDeveloperMode, - onCheckedChange = { enabled -> - if (enabled) HapticUtil.performToggleOn(haptics) else HapticUtil.performToggleOff( - haptics - ) - onToggleDeveloperMode(enabled) - } - ) - } + Column(modifier = Modifier.fillMaxWidth()) { + IconToggleItem( + iconRes = R.drawable.rounded_troubleshoot_24, + title = "Developer Mode", + isChecked = isDeveloperMode, + onCheckedChange = onToggleDeveloperMode + ) - if (isDeveloperMode) { - Spacer(modifier = Modifier.height(16.dp)) + if (isDeveloperMode) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 12.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { Text( "Test Functions", style = MaterialTheme.typography.titleSmall, color = MaterialTheme.colorScheme.primary ) - Spacer(modifier = Modifier.height(8.dp)) + Button( + onClick = { + HapticUtil.performClick(haptics) + onSendDeviceInfo() + }, + modifier = Modifier.fillMaxWidth(), + enabled = !isLoading + ) { + Text("Send Device Info") + } - Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { - Button( - onClick = { - HapticUtil.performClick(haptics) - onSendDeviceInfo() - }, - modifier = Modifier.fillMaxWidth(), - enabled = !isLoading - ) { - Text("Send Device Info") - } + Button( + onClick = { + HapticUtil.performClick(haptics) + onSendNotification() + }, + modifier = Modifier.fillMaxWidth(), + enabled = !isLoading + ) { + Text("Send Test Notification") + } + Button( + onClick = { + HapticUtil.performClick(haptics) + onSendDeviceStatus() + }, + modifier = Modifier.fillMaxWidth(), + enabled = !isLoading + ) { + Text("Send Device Status") + } + + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { Button( onClick = { HapticUtil.performClick(haptics) - onSendNotification() + onExportData() }, - modifier = Modifier.fillMaxWidth(), + modifier = Modifier.weight(1f), enabled = !isLoading ) { - Text("Send Test Notification") + Text("Export Data") } Button( onClick = { HapticUtil.performClick(haptics) - onSendDeviceStatus() + onImportData() }, - modifier = Modifier.fillMaxWidth(), + modifier = Modifier.weight(1f), enabled = !isLoading ) { - Text("Send Device Status") + Text("Import Data") } + } - Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { - Button( - onClick = { - HapticUtil.performClick(haptics) - onExportData() - }, - modifier = Modifier.weight(1f), - enabled = !isLoading - ) { - Text("Export Data") - } + Button( + onClick = { + HapticUtil.performClick(haptics) + onResetOnboarding() + }, + modifier = Modifier.fillMaxWidth(), + enabled = !isLoading + ) { + Text("Reset Onboarding") + } - Button( - onClick = { - HapticUtil.performClick(haptics) - onImportData() - }, - modifier = Modifier.weight(1f), - enabled = !isLoading - ) { - Text("Import Data") - } - } + Spacer(modifier = Modifier.height(8.dp)) + Text( + "Icons", + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.primary + ) - Button( - onClick = { - HapticUtil.performClick(haptics) - onResetOnboarding() - }, - modifier = Modifier.fillMaxWidth(), - enabled = !isLoading - ) { - Text("Reset Onboarding") + Button( + onClick = { + HapticUtil.performClick(haptics) + onManualSyncIcons() + }, + modifier = Modifier.fillMaxWidth(), + enabled = isConnected && !isIconSyncLoading + ) { + if (isIconSyncLoading) { + CircularProgressIndicator( + modifier = Modifier.width(16.dp), + strokeWidth = 2.dp, + color = MaterialTheme.colorScheme.onPrimary + ) + Spacer(modifier = Modifier.width(8.dp)) } + Text(if (isIconSyncLoading) "Syncing Icons..." else "Sync App Icons") + } - // Consolidated Icon Sync Section - Spacer(modifier = Modifier.height(8.dp)) - Text( - "Icons", - style = MaterialTheme.typography.titleSmall, - color = MaterialTheme.colorScheme.primary - ) - - Button( - onClick = { - HapticUtil.performClick(haptics) - onManualSyncIcons() - }, + AnimatedVisibility( + visible = iconSyncMessage.isNotEmpty(), + enter = expandVertically() + fadeIn(), + exit = shrinkVertically() + fadeOut() + ) { + Card( modifier = Modifier.fillMaxWidth(), - enabled = isConnected && !isIconSyncLoading - ) { - if (isIconSyncLoading) { - CircularProgressIndicator( - modifier = Modifier.width(16.dp), - strokeWidth = 2.dp, - color = MaterialTheme.colorScheme.onPrimary - ) - Spacer(modifier = Modifier.width(8.dp)) - } - Text(if (isIconSyncLoading) "Syncing Icons..." else "Sync App Icons") - } - - AnimatedVisibility( - visible = iconSyncMessage.isNotEmpty(), - enter = expandVertically() + fadeIn(), - exit = shrinkVertically() + fadeOut() + shape = MaterialTheme.shapes.extraSmall, + colors = CardDefaults.cardColors( + containerColor = if (iconSyncMessage.contains("Successfully")) + MaterialTheme.colorScheme.primaryContainer + else MaterialTheme.colorScheme.errorContainer + ) ) { - Card( - modifier = Modifier.fillMaxWidth(), - shape = MaterialTheme.shapes.extraSmall, - colors = CardDefaults.cardColors( - containerColor = if (iconSyncMessage.contains("Successfully")) - MaterialTheme.colorScheme.primaryContainer - else MaterialTheme.colorScheme.errorContainer - ) + Row( + modifier = Modifier + .fillMaxWidth() + .padding(12.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically ) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(12.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - Text( - text = iconSyncMessage, - modifier = Modifier.weight(1f), - style = MaterialTheme.typography.bodySmall, - color = if (iconSyncMessage.contains("Successfully")) - MaterialTheme.colorScheme.onPrimaryContainer - else MaterialTheme.colorScheme.onErrorContainer - ) - TextButton(onClick = { - HapticUtil.performClick(haptics) - onClearIconSyncMessage() - }) { - Text("Dismiss", style = MaterialTheme.typography.labelMedium) - } + Text( + text = iconSyncMessage, + modifier = Modifier.weight(1f), + style = MaterialTheme.typography.bodySmall, + color = if (iconSyncMessage.contains("Successfully")) + MaterialTheme.colorScheme.onPrimaryContainer + else MaterialTheme.colorScheme.onErrorContainer + ) + TextButton(onClick = { + HapticUtil.performClick(haptics) + onClearIconSyncMessage() + }) { + Text("Dismiss", style = MaterialTheme.typography.labelMedium) } } } - // Sentry section - Spacer(modifier = Modifier.height(8.dp)) - Text( - "Crash Reporting", - style = MaterialTheme.typography.titleSmall, - color = MaterialTheme.colorScheme.primary - ) + } - Button( - onClick = { - HapticUtil.performClick(haptics) - throw RuntimeException("Test Crash from Developer Options") - }, - modifier = Modifier.fillMaxWidth() - ) { - Text("Simulate Crash") - } + Spacer(modifier = Modifier.height(8.dp)) + Text( + "Crash Reporting", + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.primary + ) + + Button( + onClick = { + HapticUtil.performClick(haptics) + throw RuntimeException("Test Crash from Developer Options") + }, + modifier = Modifier.fillMaxWidth() + ) { + Text("Simulate Crash") } } } } +} } \ No newline at end of file diff --git a/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/cards/DeviceInfoCard.kt b/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/cards/DeviceInfoCard.kt index 315e7766..a4523d51 100644 --- a/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/cards/DeviceInfoCard.kt +++ b/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/cards/DeviceInfoCard.kt @@ -19,27 +19,33 @@ fun DeviceInfoCard( deviceName: String, localIp: String, onDeviceNameChange: (String) -> Unit, + modifier: Modifier = Modifier ) { Card( - modifier = Modifier.fillMaxWidth(), + modifier = modifier.fillMaxWidth(), shape = MaterialTheme.shapes.extraSmall, colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surfaceContainerHighest + containerColor = MaterialTheme.colorScheme.surfaceBright ) ) { - Column(modifier = Modifier.padding(16.dp)) { - Text("My Android", style = MaterialTheme.typography.titleMedium) - Spacer(modifier = Modifier.height(8.dp)) - Text("Local IP: $localIp", style = MaterialTheme.typography.bodyMedium) + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp) + ) { + Text("My Android", style = MaterialTheme.typography.titleMedium) + Spacer(modifier = Modifier.height(8.dp)) + Text("Local IP: $localIp", style = MaterialTheme.typography.bodyMedium) - Spacer(modifier = Modifier.height(8.dp)) - OutlinedTextField( - value = deviceName, - onValueChange = onDeviceNameChange, - label = { Text("Device Name") }, - modifier = Modifier.fillMaxWidth(), - shape = MaterialTheme.shapes.medium, - ) - } + Spacer(modifier = Modifier.height(8.dp)) + OutlinedTextField( + value = deviceName, + onValueChange = onDeviceNameChange, + label = { Text("Device Name") }, + modifier = Modifier.fillMaxWidth(), + shape = MaterialTheme.shapes.medium, + ) } } +} + 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 ef387993..710a6db7 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 @@ -1,15 +1,6 @@ package com.sameerasw.airsync.presentation.ui.components.cards import android.content.Context -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.material3.Card -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Switch -import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -18,14 +9,15 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalHapticFeedback -import androidx.compose.ui.unit.dp +import com.sameerasw.airsync.R import com.sameerasw.airsync.data.local.DataStoreManager -import com.sameerasw.airsync.utils.HapticUtil import kotlinx.coroutines.launch @Composable -fun ExpandNetworkingCard(context: Context) { +fun ExpandNetworkingCard( + context: Context, + modifier: Modifier = Modifier +) { val ds = remember { DataStoreManager(context) } val scope = rememberCoroutineScope() val enabledFlow = ds.getExpandNetworkingEnabled() @@ -37,42 +29,18 @@ fun ExpandNetworkingCard(context: Context) { } } - Card( - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 0.dp, horizontal = 0.dp), - shape = MaterialTheme.shapes.extraSmall, - colors = androidx.compose.material3.CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surfaceContainerHighest - ) - ) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(16.dp), - horizontalArrangement = Arrangement.SpaceBetween - ) { - Column { - 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 - ) + IconToggleItem( + modifier = modifier, + iconRes = R.drawable.rounded_android_wifi_3_bar_24, + title = "Expand networking", + description = "Allow connecting to device outside the local network", + isChecked = enabled, + onCheckedChange = { value -> + enabled = value + scope.launch { + ds.setExpandNetworkingEnabled(value) } - val haptics = LocalHapticFeedback.current - Switch( - checked = enabled, - onCheckedChange = { - enabled = it - if (it) HapticUtil.performToggleOn(haptics) else HapticUtil.performToggleOff( - haptics - ) - scope.launch { - ds.setExpandNetworkingEnabled(it) - } - } - ) } - } + ) } + diff --git a/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/cards/IconToggleItem.kt b/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/cards/IconToggleItem.kt index fd3e4fba..86de817a 100644 --- a/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/cards/IconToggleItem.kt +++ b/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/cards/IconToggleItem.kt @@ -1,16 +1,14 @@ package com.sameerasw.airsync.presentation.ui.components.cards -import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size -import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Switch @@ -19,78 +17,86 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalHapticFeedback -import androidx.compose.ui.platform.LocalView import androidx.compose.ui.res.painterResource import androidx.compose.ui.unit.dp +import com.sameerasw.airsync.R import com.sameerasw.airsync.utils.HapticUtil @Composable fun IconToggleItem( - iconRes: Int, title: String, modifier: Modifier = Modifier, + iconRes: Int? = null, description: String? = null, - isChecked: Boolean, - onCheckedChange: (Boolean) -> Unit, + isChecked: Boolean = false, + onCheckedChange: ((Boolean) -> Unit)? = null, enabled: Boolean = true, onDisabledClick: (() -> Unit)? = null, - showToggle: Boolean = true + showToggle: Boolean = true, + onClick: (() -> Unit)? = null, + trailingIcon: Int? = null ) { val haptics = LocalHapticFeedback.current - Row( - modifier = modifier - .fillMaxWidth() - .background( - color = MaterialTheme.colorScheme.surfaceBright, - shape = RoundedCornerShape(MaterialTheme.shapes.extraSmall.bottomEnd) - ) - .clickable(enabled = !showToggle && enabled) { - if (enabled) { - HapticUtil.performClick(haptics) - onCheckedChange(!isChecked) - } else if (onDisabledClick != null) { - HapticUtil.performClick(haptics) - onDisabledClick() - } - } - .padding(12.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(12.dp) - ) { - Spacer(modifier = Modifier.size(2.dp)) - Icon( - painter = painterResource(id = iconRes), - contentDescription = title, - modifier = Modifier.size(24.dp), - tint = MaterialTheme.colorScheme.primary + Card( + modifier = modifier.fillMaxWidth(), + shape = MaterialTheme.shapes.extraSmall, + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceBright ) - Spacer(modifier = Modifier.size(2.dp)) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable( + enabled = enabled || onDisabledClick != null, + onClick = { + if (enabled) { + HapticUtil.performClick(haptics) + if (onClick != null) { + onClick() + } else if (onCheckedChange != null && showToggle) { + onCheckedChange(!isChecked) + } + } else if (onDisabledClick != null) { + HapticUtil.performClick(haptics) + onDisabledClick() + } + } + ) + .padding(horizontal = 16.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + if (iconRes != null) { + Icon( + painter = painterResource(id = iconRes), + contentDescription = title, + modifier = Modifier.size(24.dp), + tint = if (enabled) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f) + ) + } - if (description != null) { - Column(modifier = Modifier.weight(1f)) { + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.Center + ) { Text( text = title, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurface - ) - Text( - text = description, - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant + style = MaterialTheme.typography.bodyLarge, + color = if (enabled) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f) ) + if (description != null) { + Text( + text = description, + style = MaterialTheme.typography.bodyMedium, + color = if (enabled) MaterialTheme.colorScheme.onSurfaceVariant else MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.38f), + modifier = Modifier.padding(top = 2.dp) + ) + } } - } else { - Text( - text = title, - style = MaterialTheme.typography.bodyMedium, - modifier = Modifier.weight(1f), - color = MaterialTheme.colorScheme.onSurface - ) - } - if (showToggle) { - Box { + if (showToggle && onCheckedChange != null) { Switch( checked = if (enabled) isChecked else false, onCheckedChange = { checked -> @@ -101,15 +107,13 @@ fun IconToggleItem( }, enabled = enabled ) - - if (!enabled && onDisabledClick != null) { - Box(modifier = Modifier - .matchParentSize() - .clickable { - HapticUtil.performClick(haptics) - onDisabledClick() - }) - } + } else if (onClick != null && !showToggle) { + Icon( + painter = painterResource(id = trailingIcon ?: R.drawable.rounded_keyboard_arrow_right_24), + contentDescription = null, + modifier = Modifier.size(24.dp), + tint = if (enabled) MaterialTheme.colorScheme.onSurfaceVariant else MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.38f) + ) } } } diff --git a/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/cards/LastConnectedDeviceCard.kt b/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/cards/LastConnectedDeviceCard.kt index 7f216422..0eebfbe5 100644 --- a/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/cards/LastConnectedDeviceCard.kt +++ b/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/cards/LastConnectedDeviceCard.kt @@ -4,25 +4,35 @@ import androidx.compose.foundation.Image 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.padding import androidx.compose.foundation.layout.requiredHeight +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Button import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Switch +import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable +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.layout.ContentScale import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp +import com.sameerasw.airsync.R import com.sameerasw.airsync.domain.model.ConnectedDevice +import com.sameerasw.airsync.presentation.ui.components.sheets.ConnectionSettingsBottomSheet import com.sameerasw.airsync.utils.DevicePreviewResolver import com.sameerasw.airsync.utils.HapticUtil @@ -32,130 +42,131 @@ fun LastConnectedDeviceCard( isAutoReconnectEnabled: Boolean, onToggleAutoReconnect: (Boolean) -> Unit, onQuickConnect: () -> Unit, + modifier: Modifier = Modifier ) { val haptics = LocalHapticFeedback.current + var showBottomSheet by remember { mutableStateOf(false) } Card( - modifier = Modifier.fillMaxWidth(), + modifier = modifier.fillMaxWidth(), shape = MaterialTheme.shapes.extraSmall, + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceBright + ) ) { Column( - modifier = Modifier.padding(16.dp), + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) ) { - Text( - "Last Connected Device", - style = MaterialTheme.typography.titleLarge, - color = MaterialTheme.colorScheme.primary + Text( + "Last Connected Device", + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.primary + ) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceAround, + verticalAlignment = Alignment.CenterVertically + ) { + val previewRes = DevicePreviewResolver.getPreviewRes(device) + Image( + painter = painterResource(id = previewRes), + contentDescription = "Connected Mac preview", + modifier = Modifier.fillMaxWidth(0.45f), + contentScale = ContentScale.Fit, + colorFilter = androidx.compose.ui.graphics.ColorFilter.tint(MaterialTheme.colorScheme.primary) ) + } - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceAround, - verticalAlignment = Alignment.CenterVertically - ) { - val previewRes = DevicePreviewResolver.getPreviewRes(device) - Image( - painter = painterResource(id = previewRes), - contentDescription = "Connected Mac preview", - modifier = Modifier - .fillMaxWidth(0.45f), - contentScale = ContentScale.Fit, - colorFilter = androidx.compose.ui.graphics.ColorFilter.tint(MaterialTheme.colorScheme.primary) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + "${device.name}", + style = MaterialTheme.typography.headlineSmall, + color = MaterialTheme.colorScheme.onSurface ) - } - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - Column{ - - Text( - "${device.name}", - style = MaterialTheme.typography.headlineSmall - ) - - val lastConnectedTime = remember(device.lastConnected) { - val currentTime = System.currentTimeMillis() - val diffMinutes = (currentTime - device.lastConnected) / (1000 * 60) - when { - diffMinutes < 1 -> "Just now" - diffMinutes < 60 -> "${diffMinutes}m ago" - diffMinutes < 1440 -> "${diffMinutes / 60}h ago" - else -> "${diffMinutes / 1440}d ago" - } + val lastConnectedTime = remember(device.lastConnected) { + val currentTime = System.currentTimeMillis() + val diffMinutes = (currentTime - device.lastConnected) / (1000 * 60) + when { + diffMinutes < 1 -> "Just now" + diffMinutes < 60 -> "${diffMinutes}m ago" + diffMinutes < 1440 -> "${diffMinutes / 60}h ago" + else -> "${diffMinutes / 1440}d ago" } - Text( - "Last seen $lastConnectedTime", - style = MaterialTheme.typography.bodyMedium - ) - - } - - // Display status badge - PLUS or FREE - Card( - colors = CardDefaults.cardColors( - containerColor = if (device.isPlus) - MaterialTheme.colorScheme.primaryContainer - else - MaterialTheme.colorScheme.surfaceVariant - ), - modifier = Modifier.padding(start = 8.dp) - ) { - Text( - text = if (device.isPlus) "PLUS" else "FREE", - modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp), - style = MaterialTheme.typography.labelSmall, - color = if (device.isPlus) - MaterialTheme.colorScheme.onPrimaryContainer - else - MaterialTheme.colorScheme.onSurfaceVariant - ) } + Text( + "Last seen $lastConnectedTime", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) } -// device.deviceType?.let { type -> -// Text("Type: $type", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) -// } - - - - Button( - onClick = { - HapticUtil.performClick(haptics) - onQuickConnect() - }, - modifier = Modifier - .fillMaxWidth() - .requiredHeight(65.dp) - .padding(top = 16.dp), + Surface( + shape = RoundedCornerShape(8.dp), + color = if (device.isPlus) + MaterialTheme.colorScheme.primaryContainer + else + MaterialTheme.colorScheme.surfaceVariant, + modifier = Modifier.padding(start = 8.dp) ) { - Icon( - painter = painterResource(id = com.sameerasw.airsync.R.drawable.rounded_sync_desktop_24), - contentDescription = "Quick connect", - modifier = Modifier.padding(end = 12.dp), -// tint = MaterialTheme.colorScheme.primary + Text( + text = if (device.isPlus) "PLUS" else "FREE", + modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp), + style = MaterialTheme.typography.labelSmall, + color = if (device.isPlus) + MaterialTheme.colorScheme.onPrimaryContainer + else + MaterialTheme.colorScheme.onSurfaceVariant ) - Text("Quick Connect") } + } - // Auto-reconnect toggle - Row( - modifier = Modifier - .fillMaxWidth().padding(top = 8.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - Text("Auto reconnect", style = MaterialTheme.typography.bodyMedium) - Switch(checked = isAutoReconnectEnabled, onCheckedChange = { enabled -> - if (enabled) HapticUtil.performToggleOn(haptics) else HapticUtil.performToggleOff( - haptics - ) - onToggleAutoReconnect(enabled) - }) + Button( + onClick = { + HapticUtil.performClick(haptics) + onQuickConnect() + }, + modifier = Modifier + .fillMaxWidth() + .requiredHeight(48.dp) + ) { + Icon( + painter = painterResource(id = com.sameerasw.airsync.R.drawable.rounded_sync_desktop_24), + contentDescription = "Quick connect", + modifier = Modifier.size(18.dp) + ) + Spacer(modifier = Modifier.width(8.dp)) + Text("Quick Connect") + } + } + + + IconToggleItem( + iconRes = R.drawable.rounded_compare_arrows_24, + title = stringResource(R.string.bluetooth_settings_card_title), + description = stringResource(R.string.bluetooth_settings_card_desc), + showToggle = false, + onClick = { + HapticUtil.performClick(haptics) + showBottomSheet = true } + ) + if (showBottomSheet) { + ConnectionSettingsBottomSheet( + isAutoReconnectEnabled = isAutoReconnectEnabled, + onToggleAutoReconnect = onToggleAutoReconnect, + onDismissRequest = { showBottomSheet = false } + ) } - } +} } \ No newline at end of file diff --git a/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/cards/ManualConnectionCard.kt b/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/cards/ManualConnectionCard.kt index 2f32ca0b..2ec23551 100644 --- a/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/cards/ManualConnectionCard.kt +++ b/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/cards/ManualConnectionCard.kt @@ -1,7 +1,6 @@ package com.sameerasw.airsync.presentation.ui.components.cards import androidx.compose.animation.AnimatedVisibility -import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -12,6 +11,7 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material3.Button import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField @@ -43,111 +43,110 @@ fun ManualConnectionCard( onIsPlusChange: (Boolean) -> Unit, onSymmetricKeyChange: (String) -> Unit, onConnect: () -> Unit, - onQrScanClick: (() -> Unit)? = null + onQrScanClick: (() -> Unit)? = null, + modifier: Modifier = Modifier ) { val haptics = LocalHapticFeedback.current var expanded by remember { mutableStateOf(false) } Card( - modifier = Modifier.fillMaxWidth(), + modifier = modifier.fillMaxWidth(), shape = MaterialTheme.shapes.extraSmall, + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceBright + ) ) { - Column(modifier = Modifier.padding(16.dp)) { - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.clickable { - HapticUtil.performLightTick(haptics) - expanded = !expanded - } - ) { - Text("Manual Connection", style = MaterialTheme.typography.titleMedium) - Spacer(Modifier.weight(1f)) - Icon( - painter = painterResource( - if (expanded) R.drawable.outline_expand_circle_up_24 else R.drawable.outline_expand_circle_down_24 - ), - contentDescription = if (expanded) "Collapse" else "Expand" - ) - } + Column(modifier = Modifier.fillMaxWidth()) { + IconToggleItem( + iconRes = R.drawable.rounded_devices_24, + title = "Manual Connection", + description = if (expanded) "Hide connection details" else "Enter connection details manually", + showToggle = false, + onClick = { + HapticUtil.performLightTick(haptics) + expanded = !expanded + }, + trailingIcon = if (expanded) R.drawable.outline_expand_circle_up_24 else R.drawable.outline_expand_circle_down_24 + ) - AnimatedVisibility(visible = expanded) { - Column( - verticalArrangement = Arrangement.spacedBy(8.dp), - modifier = Modifier.padding(top = 16.dp) - ) { - // QR Scanner button - if (onQrScanClick != null) { - Button( - onClick = { - HapticUtil.performClick(haptics) - onQrScanClick() - }, - modifier = Modifier.fillMaxWidth(), - ) { - Icon( - painter = painterResource(id = R.drawable.rounded_qr_code_scanner_24), - contentDescription = "Scan QR Code", - modifier = Modifier - .size(20.dp) - .padding(end = 8.dp) - ) - Text("Scan QR Code") - } - } - OutlinedTextField( - value = uiState.ipAddress, - onValueChange = onIpChange, - label = { Text("IP Address") }, - modifier = Modifier.fillMaxWidth(), - shape = MaterialTheme.shapes.medium, - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number) - ) - OutlinedTextField( - value = uiState.port, - onValueChange = onPortChange, - label = { Text("Port") }, - modifier = Modifier.fillMaxWidth(), - shape = MaterialTheme.shapes.medium, - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number) - ) - OutlinedTextField( - value = uiState.manualPcName, - onValueChange = onPcNameChange, - label = { Text("PC Name (Optional)") }, - modifier = Modifier.fillMaxWidth(), - shape = MaterialTheme.shapes.medium, - ) - OutlinedTextField( - value = uiState.symmetricKey ?: "", - onValueChange = onSymmetricKeyChange, - label = { Text("Encryption Key") }, - modifier = Modifier.fillMaxWidth(), - shape = MaterialTheme.shapes.medium, - ) - Row(verticalAlignment = Alignment.CenterVertically) { - Text("AirSync+") - Spacer(Modifier.weight(1f)) - Switch( - checked = uiState.manualIsPlus, - onCheckedChange = { enabled -> - if (enabled) HapticUtil.performToggleOn(haptics) else HapticUtil.performToggleOff( - haptics - ) - onIsPlusChange(enabled) - } - ) - } + AnimatedVisibility(visible = expanded) { + Column( + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp) + ) { + if (onQrScanClick != null) { Button( onClick = { HapticUtil.performClick(haptics) - onConnect() + onQrScanClick() }, modifier = Modifier.fillMaxWidth(), ) { - Text("Connect") + Icon( + painter = painterResource(id = R.drawable.rounded_qr_code_scanner_24), + contentDescription = "Scan QR Code", + modifier = Modifier + .size(20.dp) + .padding(end = 8.dp) + ) + Text("Scan QR Code") } } + OutlinedTextField( + value = uiState.ipAddress, + onValueChange = onIpChange, + label = { Text("IP Address") }, + modifier = Modifier.fillMaxWidth(), + shape = MaterialTheme.shapes.medium, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number) + ) + OutlinedTextField( + value = uiState.port, + onValueChange = onPortChange, + label = { Text("Port") }, + modifier = Modifier.fillMaxWidth(), + shape = MaterialTheme.shapes.medium, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number) + ) + OutlinedTextField( + value = uiState.manualPcName, + onValueChange = onPcNameChange, + label = { Text("PC Name (Optional)") }, + modifier = Modifier.fillMaxWidth(), + shape = MaterialTheme.shapes.medium, + ) + OutlinedTextField( + value = uiState.symmetricKey ?: "", + onValueChange = onSymmetricKeyChange, + label = { Text("Encryption Key") }, + modifier = Modifier.fillMaxWidth(), + shape = MaterialTheme.shapes.medium, + ) + Row(verticalAlignment = Alignment.CenterVertically) { + Text("AirSync+", color = MaterialTheme.colorScheme.onSurface) + Spacer(Modifier.weight(1f)) + Switch( + checked = uiState.manualIsPlus, + onCheckedChange = { enabled -> + if (enabled) HapticUtil.performToggleOn(haptics) else HapticUtil.performToggleOff( + haptics + ) + onIsPlusChange(enabled) + } + ) + } + Button( + onClick = { + HapticUtil.performClick(haptics) + onConnect() + }, + modifier = Modifier.fillMaxWidth(), + ) { + Text("Connect") + } } } } } +} + diff --git a/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/cards/MediaSyncCard.kt b/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/cards/MediaSyncCard.kt index f7f6da44..a5c42da3 100644 --- a/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/cards/MediaSyncCard.kt +++ b/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/cards/MediaSyncCard.kt @@ -2,85 +2,34 @@ package com.sameerasw.airsync.presentation.ui.components.cards import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -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.Composable -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.utils.HapticUtil +import com.sameerasw.airsync.R @Composable fun MediaSyncCard( isSendNowPlayingEnabled: Boolean, onToggleSendNowPlaying: (Boolean) -> Unit, isMacMediaControlsEnabled: Boolean, - onToggleMacMediaControls: (Boolean) -> Unit + onToggleMacMediaControls: (Boolean) -> Unit, + modifier: Modifier = Modifier ) { - val haptics = LocalHapticFeedback.current - - Card( - modifier = Modifier.fillMaxWidth(), - shape = MaterialTheme.shapes.extraSmall, - colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surfaceContainerHighest + Column(modifier = modifier, verticalArrangement = Arrangement.spacedBy(2.dp)) { + IconToggleItem( + iconRes = R.drawable.rounded_music_cast_24, + title = "Send now playing", + description = "Share media playback details with desktop", + isChecked = isSendNowPlayingEnabled, + onCheckedChange = onToggleSendNowPlaying + ) + IconToggleItem( + iconRes = R.drawable.rounded_smart_display_24, + title = "Show Mac Media Controls", + description = "Show media controls when Mac is playing music", + isChecked = isMacMediaControlsEnabled, + onCheckedChange = onToggleMacMediaControls ) - ) { - Column(modifier = Modifier.padding(16.dp)) { - // Send Now Playing Row - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - Column(modifier = Modifier.weight(1f)) { - Text("Send now playing", style = MaterialTheme.typography.titleMedium) - Text( - "Share media playback details with desktop", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - Switch( - checked = isSendNowPlayingEnabled, - onCheckedChange = { enabled -> - if (enabled) HapticUtil.performToggleOn(haptics) else HapticUtil.performToggleOff(haptics) - onToggleSendNowPlaying(enabled) - } - ) - } - - // Mac Media Controls Row - Row( - modifier = Modifier - .fillMaxWidth() - .padding(top = 12.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - Column(modifier = Modifier.weight(1f)) { - Text("Show Mac Media Controls", style = MaterialTheme.typography.titleMedium) - Text( - "Show media controls when Mac is playing music", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - Switch( - checked = isMacMediaControlsEnabled, - onCheckedChange = { enabled -> - if (enabled) HapticUtil.performToggleOn(haptics) else HapticUtil.performToggleOff(haptics) - onToggleMacMediaControls(enabled) - } - ) - } - } } } + diff --git a/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/cards/NotificationSyncCard.kt b/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/cards/NotificationSyncCard.kt index 35f87132..af70b4f8 100644 --- a/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/cards/NotificationSyncCard.kt +++ b/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/cards/NotificationSyncCard.kt @@ -1,75 +1,31 @@ package com.sameerasw.airsync.presentation.ui.components.cards -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.material3.Card -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Switch -import androidx.compose.material3.Text import androidx.compose.runtime.Composable -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.utils.HapticUtil +import com.sameerasw.airsync.R @Composable fun NotificationSyncCard( isNotificationEnabled: Boolean, isNotificationSyncEnabled: Boolean, onToggleSync: (Boolean) -> Unit, - onGrantPermissions: () -> Unit + onGrantPermissions: () -> Unit, + modifier: Modifier = Modifier ) { - val haptics = LocalHapticFeedback.current - - Card( - modifier = Modifier.fillMaxWidth(), - shape = MaterialTheme.shapes.extraSmall, - colors = androidx.compose.material3.CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surfaceContainerHighest - ) - ) { - Column(modifier = Modifier.padding(16.dp)) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - Column(modifier = Modifier.weight(1f)) { - Text("Notification Sync", style = MaterialTheme.typography.titleMedium) - } - - Switch( - checked = isNotificationSyncEnabled && isNotificationEnabled, - onCheckedChange = { enabled -> - if (isNotificationEnabled) { - if (enabled) HapticUtil.performToggleOn(haptics) else HapticUtil.performToggleOff( - haptics - ) - onToggleSync(enabled) - } else { - HapticUtil.performClick(haptics) - onGrantPermissions() - } - }, - enabled = isNotificationEnabled - ) + IconToggleItem( + modifier = modifier, + iconRes = R.drawable.rounded_notifications_active_24, + title = "Notification Sync", + description = if (!isNotificationEnabled) "❌ Notification access required" else null, + isChecked = isNotificationSyncEnabled && isNotificationEnabled, + onCheckedChange = { enabled -> + if (isNotificationEnabled) { + onToggleSync(enabled) + } else { + onGrantPermissions() } - - if (!isNotificationEnabled) { - Spacer(modifier = Modifier.height(8.dp)) - Text( - "❌ Notification access required", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.error - ) - } - - } - } + }, + enabled = isNotificationEnabled, + onDisabledClick = onGrantPermissions + ) } \ No newline at end of file diff --git a/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/cards/PermissionsCard.kt b/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/cards/PermissionsCard.kt index 231dd38d..afd5b699 100644 --- a/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/cards/PermissionsCard.kt +++ b/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/cards/PermissionsCard.kt @@ -26,53 +26,69 @@ import com.sameerasw.airsync.utils.HapticUtil @Composable fun PermissionsCard( - missingPermissionsCount: Int = 0 + missingPermissionsCount: Int = 0, + modifier: Modifier = Modifier ) { val context = LocalContext.current val haptics = LocalHapticFeedback.current + val hasMissing = missingPermissionsCount > 0 + Card( - modifier = Modifier - .fillMaxWidth() - .clickable { - HapticUtil.performClick(haptics) - val intent = Intent(context, PermissionsActivity::class.java) - context.startActivity(intent) - }, + modifier = modifier.fillMaxWidth(), shape = MaterialTheme.shapes.extraSmall, colors = CardDefaults.cardColors( - containerColor = if (missingPermissionsCount > 0) + containerColor = if (hasMissing) MaterialTheme.colorScheme.errorContainer else - MaterialTheme.colorScheme.surfaceContainerHighest + MaterialTheme.colorScheme.surfaceBright ) ) { Row( modifier = Modifier .fillMaxWidth() - .padding(16.dp), + .clickable { + HapticUtil.performClick(haptics) + val intent = Intent(context, PermissionsActivity::class.java) + context.startActivity(intent) + } + .padding(horizontal = 16.dp, vertical = 12.dp), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically ) { - Column(modifier = Modifier.weight(1f)) { + if (hasMissing) { + Icon( + painter = painterResource(id = R.drawable.rounded_shield_toggle_24), + contentDescription = null, + modifier = Modifier.size(24.dp), + tint = MaterialTheme.colorScheme.error + ) + } else { + Icon( + painter = painterResource(id = R.drawable.rounded_task_alt_24), + contentDescription = null, + modifier = Modifier.size(24.dp), + tint = MaterialTheme.colorScheme.primary + ) + } + + Column( + modifier = Modifier + .weight(1f) + .padding(horizontal = 12.dp) + ) { Text( "Permissions", - style = MaterialTheme.typography.titleMedium, - color = if (missingPermissionsCount > 0) - MaterialTheme.colorScheme.onErrorContainer - else - MaterialTheme.colorScheme.onSurface + style = MaterialTheme.typography.bodyLarge, + color = if (hasMissing) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurface ) Text( - if (missingPermissionsCount > 0) + if (hasMissing) "$missingPermissionsCount missing" else "All permissions granted", - style = MaterialTheme.typography.bodySmall, - color = if (missingPermissionsCount > 0) - MaterialTheme.colorScheme.onErrorContainer.copy(alpha = 0.7f) - else - MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f) + style = MaterialTheme.typography.bodyMedium, + color = if (hasMissing) MaterialTheme.colorScheme.error.copy(alpha = 0.8f) else MaterialTheme.colorScheme.onSurfaceVariant ) } @@ -80,12 +96,10 @@ fun PermissionsCard( painter = painterResource(id = R.drawable.rounded_keyboard_arrow_right_24), contentDescription = "Open permissions", modifier = Modifier.size(24.dp), - tint = if (missingPermissionsCount > 0) - MaterialTheme.colorScheme.onErrorContainer - else - MaterialTheme.colorScheme.onSurface + tint = if (hasMissing) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurfaceVariant ) } } } + diff --git a/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/cards/QuickSettingsTilesCard.kt b/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/cards/QuickSettingsTilesCard.kt index a94f9fc5..26d0c5d6 100644 --- a/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/cards/QuickSettingsTilesCard.kt +++ b/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/cards/QuickSettingsTilesCard.kt @@ -9,6 +9,7 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text @@ -31,13 +32,13 @@ import com.sameerasw.airsync.utils.QuickSettingsUtil fun QuickSettingsTilesCard( isConnectionTileAdded: Boolean, isClipboardTileAdded: Boolean, - isQuickShareTileAdded: Boolean + modifier: Modifier = Modifier ) { Card( - modifier = Modifier.fillMaxWidth(), + modifier = modifier.fillMaxWidth(), shape = MaterialTheme.shapes.extraSmall, - colors = androidx.compose.material3.CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surfaceContainerHighest + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceBright ) ) { Row( diff --git a/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/cards/SendNowPlayingCard.kt b/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/cards/SendNowPlayingCard.kt deleted file mode 100644 index 2f0205c8..00000000 --- a/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/cards/SendNowPlayingCard.kt +++ /dev/null @@ -1,64 +0,0 @@ -package com.sameerasw.airsync.presentation.ui.components.cards - -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.material3.Card -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Switch -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -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.utils.HapticUtil - -@Composable -fun SendNowPlayingCard( - isSendNowPlayingEnabled: Boolean, - onToggleSendNowPlaying: (Boolean) -> Unit, - title: String = "Send now playing", - subtitle: String = "Share media playback details with desktop", - enabled: Boolean = true -) { - val haptics = LocalHapticFeedback.current - - Card( - modifier = Modifier.fillMaxWidth(), - shape = MaterialTheme.shapes.extraSmall, - colors = androidx.compose.material3.CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surfaceContainerHighest - ) - ) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(16.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - Column(modifier = Modifier.weight(1f)) { - Text(title, style = MaterialTheme.typography.titleMedium) - Text( - subtitle, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - Switch( - checked = isSendNowPlayingEnabled, - enabled = enabled, - onCheckedChange = { enabled -> - if (enabled) HapticUtil.performToggleOn(haptics) else HapticUtil.performToggleOff( - haptics - ) - onToggleSendNowPlaying(enabled) - } - ) - } - } -} - diff --git a/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/cards/SmartspacerCard.kt b/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/cards/SmartspacerCard.kt index d4027eec..8e83052e 100644 --- a/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/cards/SmartspacerCard.kt +++ b/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/cards/SmartspacerCard.kt @@ -1,60 +1,23 @@ package com.sameerasw.airsync.presentation.ui.components.cards -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.material3.Card -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Switch -import androidx.compose.material3.Text import androidx.compose.runtime.Composable -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.utils.HapticUtil +import com.sameerasw.airsync.R @Composable fun SmartspacerCard( isSmartspacerShowWhenDisconnected: Boolean, onToggleSmartspacerShowWhenDisconnected: (Boolean) -> Unit, + modifier: Modifier = Modifier ) { - val haptics = LocalHapticFeedback.current - - Card( - modifier = Modifier.fillMaxWidth(), - shape = MaterialTheme.shapes.extraSmall, - colors = androidx.compose.material3.CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surfaceContainerHighest - ) - ) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(16.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - Column(modifier = Modifier.weight(1f)) { - Text("Smartspacer", style = MaterialTheme.typography.titleMedium) - Text( - "Show Smartspacer when disconnected", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - Switch( - checked = isSmartspacerShowWhenDisconnected, - onCheckedChange = { enabled -> - if (enabled) HapticUtil.performToggleOn(haptics) else HapticUtil.performToggleOff( - haptics - ) - onToggleSmartspacerShowWhenDisconnected(enabled) - } - ) - } - } + IconToggleItem( + modifier = modifier, + iconRes = R.drawable.rounded_asterisk_24, + title = "Smartspacer", + description = "Show Smartspacer when disconnected", + isChecked = isSmartspacerShowWhenDisconnected, + onCheckedChange = onToggleSmartspacerShowWhenDisconnected + ) } + diff --git a/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/cards/SyncFeaturesCard.kt b/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/cards/SyncFeaturesCard.kt index 612de356..f4e640a7 100644 --- a/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/cards/SyncFeaturesCard.kt +++ b/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/cards/SyncFeaturesCard.kt @@ -2,20 +2,10 @@ package com.sameerasw.airsync.presentation.ui.components.cards 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.padding -import androidx.compose.material3.Card -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Switch -import androidx.compose.material3.Text import androidx.compose.runtime.Composable -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.utils.HapticUtil +import com.sameerasw.airsync.R @Composable fun ClipboardFeaturesCard( @@ -24,103 +14,37 @@ fun ClipboardFeaturesCard( // Continue Browsing props isContinueBrowsingEnabled: Boolean, onToggleContinueBrowsing: (Boolean) -> Unit, - // New: control the UI enabled state and subtitle for Continue Browsing + // Control the UI enabled state and subtitle for Continue Browsing isContinueBrowsingToggleEnabled: Boolean, continueBrowsingSubtitle: String, - // New: Keep previous link props + // Keep previous link props isKeepPreviousLinkEnabled: Boolean, onToggleKeepPreviousLink: (Boolean) -> Unit, + modifier: Modifier = Modifier ) { - val haptics = LocalHapticFeedback.current - - Card( - modifier = Modifier.fillMaxWidth(), - shape = MaterialTheme.shapes.extraSmall, - colors = androidx.compose.material3.CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surfaceContainerHighest + Column(modifier = modifier, verticalArrangement = Arrangement.spacedBy(2.dp)) { + IconToggleItem( + iconRes = R.drawable.ic_clipboard_24, + title = "Clipboard Sync", + description = "Update Android clipboard automatically", + isChecked = isClipboardSyncEnabled, + onCheckedChange = onToggleClipboardSync + ) + IconToggleItem( + iconRes = R.drawable.outline_open_in_browser_24, + title = "Continue browsing", + description = continueBrowsingSubtitle, + isChecked = isContinueBrowsingEnabled, + onCheckedChange = onToggleContinueBrowsing, + enabled = isContinueBrowsingToggleEnabled + ) + IconToggleItem( + iconRes = R.drawable.rounded_history_24, + title = "Keep previous link", + description = "Without replacing", + isChecked = isKeepPreviousLinkEnabled, + onCheckedChange = onToggleKeepPreviousLink, + enabled = isContinueBrowsingToggleEnabled ) - ) { - Column(modifier = Modifier.padding(16.dp)) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - Column(modifier = Modifier.weight(1f)) { - Text("Clipboard Sync", style = MaterialTheme.typography.titleMedium) - Text( - "Unfortunately Google killed automatic sync, You need to manually share the text to AirSync app.", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - Switch( - checked = isClipboardSyncEnabled, - onCheckedChange = { enabled -> - if (enabled) HapticUtil.performToggleOn(haptics) else HapticUtil.performToggleOff( - haptics - ) - onToggleClipboardSync(enabled) - } - ) - } - // Continue Browsing toggle displayed under clipboard sync - Row( - modifier = Modifier - .fillMaxWidth() - .padding(top = 12.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - Column(modifier = Modifier.weight(1f)) { - Text("Continue browsing", style = MaterialTheme.typography.titleMedium) - Text( - continueBrowsingSubtitle, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - Spacer(modifier = Modifier.padding(end = 8.dp)) - Switch( - checked = isContinueBrowsingEnabled, - onCheckedChange = { enabled -> - if (enabled) HapticUtil.performToggleOn(haptics) else HapticUtil.performToggleOff( - haptics - ) - onToggleContinueBrowsing(enabled) - }, - enabled = isContinueBrowsingToggleEnabled - ) - } - - // Keep previous link toggle under Continue Browsing - Row( - modifier = Modifier - .fillMaxWidth() - .padding(top = 12.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - Column(modifier = Modifier.weight(1f)) { - Text("Keep previous link", style = MaterialTheme.typography.titleMedium) - Text( - "Keep multiple continue browsing notifications", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - Switch( - checked = isKeepPreviousLinkEnabled, - onCheckedChange = { enabled -> - if (enabled) HapticUtil.performToggleOn(haptics) else HapticUtil.performToggleOff( - haptics - ) - onToggleKeepPreviousLink(enabled) - }, - enabled = isContinueBrowsingToggleEnabled - ) - } - - } } } \ No newline at end of file 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 cd7ff320..d3ad30b4 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 @@ -26,6 +26,8 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.DialogProperties +import androidx.compose.ui.platform.LocalContext +import android.content.Context import com.sameerasw.airsync.R enum class PermissionType { @@ -35,7 +37,10 @@ enum class PermissionType { WALLPAPER_ACCESS, CALL_LOG, CONTACTS, - PHONE + PHONE, + BLUETOOTH, + LOCAL_NETWORK, + ANSWER_CALLS } data class PermissionInfo( @@ -52,7 +57,8 @@ fun PermissionExplanationDialog( onDismiss: () -> Unit, onGrantPermission: () -> Unit ) { - val permissionInfo = getPermissionInfo(permissionType) + val context = LocalContext.current + val permissionInfo = getPermissionInfo(context, permissionType) Dialog( onDismissRequest = onDismiss, @@ -154,7 +160,7 @@ fun PermissionExplanationDialog( } } -private fun getPermissionInfo(permissionType: PermissionType): PermissionInfo { +private fun getPermissionInfo(context: Context, permissionType: PermissionType): PermissionInfo { return when (permissionType) { PermissionType.NOTIFICATION_ACCESS -> PermissionInfo( title = "Notification Access", @@ -211,5 +217,29 @@ 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.BLUETOOTH -> PermissionInfo( + title = "Bluetooth Access", + icon = R.drawable.rounded_sync_desktop_24, + description = "AirSync uses Bluetooth Low Energy (BLE) as a secondary transport to sync notifications and media controls with your Mac when Wi-Fi is unavailable.", + whyNeeded = "To discover and connect to your Mac via Bluetooth, Android requires Bluetooth permissions (Scan, Connect, and Advertise). \n\nThis enables a low-power background connection that keeps your devices synced even when they aren't on the same Wi-Fi network. AirSync only uses Bluetooth to communicate with your authorized Mac devices.", + buttonText = "Grant Bluetooth Access" + ) + + PermissionType.LOCAL_NETWORK -> PermissionInfo( + title = context.getString(R.string.permission_local_network_title), + icon = R.drawable.rounded_sync_desktop_24, + description = context.getString(R.string.permission_local_network_explain), + whyNeeded = context.getString(R.string.permission_local_network_why), + buttonText = context.getString(R.string.permission_local_network_button) + ) + + PermissionType.ANSWER_CALLS -> PermissionInfo( + title = context.getString(R.string.permission_answer_calls_title), + icon = R.drawable.rounded_settings_phone_24, + description = context.getString(R.string.permission_answer_calls_explain), + whyNeeded = context.getString(R.string.permission_answer_calls_why), + buttonText = context.getString(R.string.permission_answer_calls_button) + ) } } diff --git a/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/sheets/AppSelectionSheets.kt b/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/sheets/AppSelectionSheets.kt new file mode 100644 index 00000000..48772339 --- /dev/null +++ b/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/sheets/AppSelectionSheets.kt @@ -0,0 +1,249 @@ +package com.sameerasw.airsync.presentation.ui.components.sheets + +import android.content.Context +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalView +import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import coil.compose.AsyncImage +import com.sameerasw.airsync.R +import com.sameerasw.airsync.domain.model.NotificationApp +import com.sameerasw.airsync.utils.HapticUtil + +@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class) +@Composable +fun AppSelectionSheet( + onDismissRequest: () -> Unit, + apps: List, + onAppToggle: (String, Boolean) -> Unit, + onSaveAll: (List) -> Unit, + isLoading: Boolean +) { + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + val haptics = LocalHapticFeedback.current + var searchQuery by remember { mutableStateOf("") } + var showSystemApps by remember { mutableStateOf(false) } + + val filteredApps = apps.filter { + val matchesSearch = searchQuery.isEmpty() || it.appName.contains(searchQuery, ignoreCase = true) + val isVisible = !it.isSystemApp || showSystemApps || it.isEnabled + matchesSearch && isVisible + }.distinctBy { it.packageName } + .sortedWith(compareByDescending { it.isEnabled }.thenBy { it.appName.lowercase() }) + + ModalBottomSheet( + onDismissRequest = onDismissRequest, + sheetState = sheetState, + containerColor = MaterialTheme.colorScheme.surfaceContainerHigh + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .fillMaxHeight(0.9f) + .padding(horizontal = 16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = stringResource(R.string.action_select_apps), + style = MaterialTheme.typography.headlineSmall + ) + + IconButton( + onClick = { + HapticUtil.performClick(haptics) + val updatedList = apps.map { app -> + val isVisible = !app.isSystemApp || showSystemApps || app.isEnabled + if (isVisible) app.copy(isEnabled = !app.isEnabled) else app + } + onSaveAll(updatedList) + } + ) { + Icon( + painter = painterResource(id = R.drawable.rounded_invert_colors_24), + contentDescription = "Invert Selection", + tint = MaterialTheme.colorScheme.primary + ) + } + } + + // Search Bar + OutlinedTextField( + value = searchQuery, + onValueChange = { searchQuery = it }, + modifier = Modifier.fillMaxWidth(), + placeholder = { Text("Search apps") }, + leadingIcon = { + Icon( + painter = painterResource(id = R.drawable.outline_info_24), // Fallback search icon + contentDescription = "Search" + ) + }, + singleLine = true, + shape = RoundedCornerShape(12.dp) + ) + + // System Apps Toggle + Row( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(16.dp)) + .clickable { + HapticUtil.performClick(haptics) + showSystemApps = !showSystemApps + } + .padding(8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + Icon( + painter = painterResource(id = R.drawable.rounded_android_24), + contentDescription = null, + modifier = Modifier.size(24.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + Text( + text = "Show system apps", + style = MaterialTheme.typography.bodyLarge, + modifier = Modifier.weight(1f), + color = MaterialTheme.colorScheme.onSurface + ) + Switch( + checked = showSystemApps, + onCheckedChange = { + HapticUtil.performClick(haptics) + showSystemApps = it + } + ) + } + + if (isLoading) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 32.dp), + horizontalArrangement = Arrangement.Center + ) { + CircularProgressIndicator() + } + } else { + LazyColumn( + modifier = Modifier + .weight(1f) + .clip(RoundedCornerShape(24.dp)), + verticalArrangement = Arrangement.spacedBy(2.dp) + ) { + items(filteredApps, key = { it.packageName }) { app -> + AppToggleItem( + icon = app.icon, + title = app.appName, + packageName = app.packageName, + isSystemApp = app.isSystemApp, + isChecked = app.isEnabled, + onCheckedChange = { isChecked -> + HapticUtil.performClick(haptics) + onAppToggle(app.packageName, isChecked) + } + ) + } + } + } + Spacer(modifier = Modifier.height(16.dp)) + } + } +} + +@OptIn(ExperimentalMaterial3ExpressiveApi::class) +@Composable +fun AppToggleItem( + icon: Any?, + title: String, + modifier: Modifier = Modifier, + description: String? = null, + packageName: String? = null, + isSystemApp: Boolean = false, + isChecked: Boolean, + onCheckedChange: (Boolean) -> Unit, + enabled: Boolean = true +) { + val haptics = LocalHapticFeedback.current + val shouldShowSystemTag = isSystemApp + + ListItem( + modifier = modifier + .fillMaxWidth() + .clickable(enabled = enabled) { + HapticUtil.performClick(haptics) + onCheckedChange(!isChecked) + }, + leadingContent = { + if (icon != null) { + AsyncImage( + model = icon, + contentDescription = title, + modifier = Modifier + .size(32.dp) + .clip(RoundedCornerShape(8.dp)) + ) + } else { + Spacer(modifier = Modifier.size(32.dp)) + } + }, + headlineContent = { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp) + ) { + Text( + text = title, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface + ) + if (shouldShowSystemTag) { + Icon( + painter = painterResource(id = R.drawable.rounded_android_24), + contentDescription = "System App", + modifier = Modifier.size(14.dp), + tint = MaterialTheme.colorScheme.primary + ) + } + } + }, + supportingContent = if (description != null) { + { + Text( + text = description, + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } else null, + trailingContent = { + Switch( + checked = if (enabled) isChecked else false, + onCheckedChange = null, + enabled = enabled + ) + }, + colors = ListItemDefaults.colors( + containerColor = MaterialTheme.colorScheme.surfaceBright + ) + ) +} diff --git a/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/sheets/ConnectionSettingsBottomSheet.kt b/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/sheets/ConnectionSettingsBottomSheet.kt new file mode 100644 index 00000000..030fb7e4 --- /dev/null +++ b/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/sheets/ConnectionSettingsBottomSheet.kt @@ -0,0 +1,90 @@ +package com.sameerasw.airsync.presentation.ui.components.sheets + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.Text +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import com.sameerasw.airsync.R +import com.sameerasw.airsync.data.local.DataStoreManager +import com.sameerasw.airsync.presentation.ui.components.RoundedCardContainer +import com.sameerasw.airsync.presentation.ui.components.cards.IconToggleItem +import kotlinx.coroutines.launch + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ConnectionSettingsBottomSheet( + isAutoReconnectEnabled: Boolean, + onToggleAutoReconnect: (Boolean) -> Unit, + onDismissRequest: () -> Unit +) { + val context = LocalContext.current + val dataStoreManager = remember { DataStoreManager.getInstance(context) } + val scope = rememberCoroutineScope() + + val bleEnabled by dataStoreManager.getBleSyncEnabled().collectAsState(initial = false) + + ModalBottomSheet( + onDismissRequest = onDismissRequest, + sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = false), + containerColor = MaterialTheme.colorScheme.surfaceContainerHigh, + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + .padding(bottom = 32.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + Text( + text = stringResource(R.string.bluetooth_settings_card_title), + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.padding(top = 8.dp) + ) + + RoundedCardContainer { + IconToggleItem( + iconRes = R.drawable.rounded_sync_desktop_24, + title = stringResource(R.string.setting_auto_reconnect_title), + description = stringResource(R.string.setting_auto_reconnect_desc), + isChecked = isAutoReconnectEnabled, + onCheckedChange = { enabled -> + onToggleAutoReconnect(enabled) + } + ) + + IconToggleItem( + iconRes = R.drawable.rounded_bluetooth_24, + title = stringResource(R.string.setting_nearby_connection_title), + description = stringResource(R.string.setting_nearby_connection_desc), + isChecked = bleEnabled, + onCheckedChange = { enabled -> + scope.launch { + dataStoreManager.setBleSyncEnabled(enabled) + dataStoreManager.setBleAutoConnectEnabled(enabled) + } + } + ) + } + } + } +} + diff --git a/app/src/main/java/com/sameerasw/airsync/presentation/ui/composables/WelcomeScreen.kt b/app/src/main/java/com/sameerasw/airsync/presentation/ui/composables/WelcomeScreen.kt index 9398bdd6..447cc645 100644 --- a/app/src/main/java/com/sameerasw/airsync/presentation/ui/composables/WelcomeScreen.kt +++ b/app/src/main/java/com/sameerasw/airsync/presentation/ui/composables/WelcomeScreen.kt @@ -453,10 +453,10 @@ fun FeatureIntroStepContent( context, com.sameerasw.airsync.service.ClipboardTileService::class.java ), - isQuickShareTileAdded = com.sameerasw.airsync.utils.QuickSettingsUtil.isQSTileAdded( - context, - - ) +// isQuickShareTileAdded = com.sameerasw.airsync.utils.QuickSettingsUtil.isQSTileAdded( +// context, +// +// ) ) } 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 8027e0dc..768ad625 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 @@ -14,6 +14,7 @@ import androidx.compose.animation.fadeOut import androidx.compose.animation.shrinkHorizontally import androidx.compose.animation.shrinkVertically import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -54,6 +55,7 @@ import androidx.compose.material.icons.rounded.Phonelink import androidx.compose.material.icons.rounded.Settings import androidx.compose.material3.AlertDialog import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults import androidx.compose.material3.CircularWavyProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi @@ -85,6 +87,7 @@ import androidx.compose.runtime.setValue import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip import androidx.compose.ui.hapticfeedback.HapticFeedbackType import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.platform.LocalContext @@ -841,8 +844,15 @@ fun AirSyncMainScreen( Card( modifier = Modifier.fillMaxWidth(), shape = MaterialTheme.shapes.extraSmall, + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceBright + ) ) { - Column(modifier = Modifier.padding(16.dp)) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp) + ) { Row( modifier = Modifier .fillMaxWidth() @@ -905,18 +915,14 @@ fun AirSyncMainScreen( discoveredDevices.forEachIndexed { index, device -> if (index > 0) { - HorizontalDivider( - modifier = Modifier.padding(vertical = 8.dp), - thickness = 0.5.dp, - color = MaterialTheme.colorScheme.outlineVariant.copy( - alpha = 0.5f - ) - ) + Spacer(modifier = Modifier.height(8.dp)) } Row( modifier = Modifier .fillMaxWidth() + .clip(MaterialTheme.shapes.medium) + .background(MaterialTheme.colorScheme.surfaceContainerHigh) .clickable { HapticUtil.performClick(haptics) viewModel.updateIpAddress(device.getBestIp()) @@ -926,7 +932,7 @@ fun AirSyncMainScreen( ) connect(device.id) } - .padding(vertical = 4.dp), + .padding(horizontal = 16.dp, vertical = 12.dp), verticalAlignment = Alignment.CenterVertically ) { Icon( 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 1a04029f..f051f478 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 @@ -43,6 +43,9 @@ fun PermissionsScreen( onRequestCallLogPermission: (() -> Unit)? = null, onRequestContactsPermission: (() -> Unit)? = null, onRequestPhonePermission: (() -> Unit)? = null, + onRequestBluetoothPermission: (() -> Unit)? = null, + onRequestLocalNetworkPermission: (() -> Unit)? = null, + onRequestAnswerCallsPermission: (() -> Unit)? = null, refreshTrigger: Int = 0 ) { val context = LocalContext.current @@ -219,6 +222,33 @@ fun PermissionsScreen( isCritical = false ) } + + "Bluetooth Access" -> { + PermissionButton( + permissionName = permission, + description = "Enables background BLE sync", + onExplainClick = { showDialog = PermissionType.BLUETOOTH }, + isCritical = false + ) + } + + "Local Network Access" -> { + PermissionButton( + permissionName = permission, + description = "Discover nearby Mac devices on Wi-Fi", + onExplainClick = { showDialog = PermissionType.LOCAL_NETWORK }, + isCritical = false + ) + } + + "Answer Calls" -> { + PermissionButton( + permissionName = permission, + description = "Accept and end calls from Mac", + onExplainClick = { showDialog = PermissionType.ANSWER_CALLS }, + isCritical = false + ) + } } } } @@ -263,6 +293,18 @@ fun PermissionsScreen( PermissionType.PHONE -> { onRequestPhonePermission?.invoke() } + + PermissionType.BLUETOOTH -> { + onRequestBluetoothPermission?.invoke() + } + + PermissionType.LOCAL_NETWORK -> { + onRequestLocalNetworkPermission?.invoke() + } + + PermissionType.ANSWER_CALLS -> { + onRequestAnswerCallsPermission?.invoke() + } } } ) diff --git a/app/src/main/java/com/sameerasw/airsync/presentation/viewmodel/AirSyncViewModel.kt b/app/src/main/java/com/sameerasw/airsync/presentation/viewmodel/AirSyncViewModel.kt index bd8ba0fe..2e99cd73 100644 --- a/app/src/main/java/com/sameerasw/airsync/presentation/viewmodel/AirSyncViewModel.kt +++ b/app/src/main/java/com/sameerasw/airsync/presentation/viewmodel/AirSyncViewModel.kt @@ -26,6 +26,7 @@ import com.sameerasw.airsync.utils.ShortcutUtil import com.sameerasw.airsync.utils.SyncManager import com.sameerasw.airsync.utils.UDPDiscoveryManager import com.sameerasw.airsync.utils.WebSocketUtil +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow @@ -94,24 +95,27 @@ class AirSyncViewModel( } // Connection status listener for WebSocket updates - private val connectionStatusListener: (Boolean) -> Unit = { isConnected -> + private val connectionStatusListener: (Boolean) -> Unit = { isWsConnected -> viewModelScope.launch { + val isBleConnected = _uiState.value.bleConnectionState == com.sameerasw.airsync.data.ble.BleGattServer.BleConnectionState.AUTHENTICATED + val isGlobalConnected = isWsConnected || isBleConnected + _uiState.value = _uiState.value.copy( - isConnected = isConnected, + isConnected = isGlobalConnected, isConnecting = false, - response = if (isConnected) "Connected successfully!" else "Disconnected", - activeIp = if (isConnected) WebSocketUtil.currentIpAddress else null, - macDeviceStatus = if (isConnected) _uiState.value.macDeviceStatus else null + response = if (isGlobalConnected) "Connected successfully!" else "Disconnected", + activeIp = if (isWsConnected) WebSocketUtil.currentIpAddress else null, + macDeviceStatus = if (isGlobalConnected) _uiState.value.macDeviceStatus else null ) - if (isConnected) { + if (isGlobalConnected) { repository.setFirstMacConnectionTime(System.currentTimeMillis()) updateRatingPromptDisplay() } // Update dynamic shortcuts appContext?.let { ctx -> - ShortcutUtil.refreshShortcuts(ctx, isConnected) + ShortcutUtil.refreshShortcuts(ctx, isGlobalConnected) } // Notify Smartspacer of connection status change @@ -180,6 +184,35 @@ class AirSyncViewModel( _uiState.value = _uiState.value.copy(isQuickShareEnabled = enabled) } } + + // Observe File Access preference + viewModelScope.launch { + repository.isFileAccessEnabled().collect { enabled -> + _uiState.value = _uiState.value.copy(isFileAccessEnabled = enabled) + } + } + + // Observe BLE connection status + viewModelScope.launch { + com.sameerasw.airsync.AirSyncApp.getBleConnectionManager()?.connectionState?.collect { state -> + Log.d("AirSyncViewModel", "BLE connection state changed: $state") + val isBleAuthenticated = state == com.sameerasw.airsync.data.ble.BleGattServer.BleConnectionState.AUTHENTICATED + val isWsConnected = WebSocketUtil.isConnected() + + _uiState.value = _uiState.value.copy( + bleConnectionState = state, + isConnected = isWsConnected || isBleAuthenticated + ) + + if (isBleAuthenticated && !isWsConnected) { + // Refresh shortcuts and other side effects if this is the only connection + appContext?.let { ctx -> + ShortcutUtil.refreshShortcuts(ctx, true) + } + updateRatingPromptDisplay() + } + } + } } override fun onCleared() { @@ -666,6 +699,14 @@ class AirSyncViewModel( } } + fun setFileAccessEnabled(context: Context, enabled: Boolean) { + _uiState.value = _uiState.value.copy(isFileAccessEnabled = enabled) + viewModelScope.launch { + repository.setFileAccessEnabled(enabled) + ServiceManager.updateServiceState(context) + } + } + fun manualSyncAppIcons(context: Context) { _uiState.value = _uiState.value.copy(isIconSyncLoading = true, iconSyncMessage = "") @@ -1092,4 +1133,45 @@ class AirSyncViewModel( } } + private val _notificationApps = MutableStateFlow>(emptyList()) + val notificationApps: StateFlow> = _notificationApps.asStateFlow() + + fun loadNotificationApps(context: Context) { + viewModelScope.launch(Dispatchers.IO) { + try { + val installed = com.sameerasw.airsync.utils.AppUtil.getInstalledApps(context) + val saved = repository.getNotificationApps().first() + val merged = com.sameerasw.airsync.utils.AppUtil.mergeWithSavedApps(installed, saved) + _notificationApps.value = merged + } catch (e: Exception) { + Log.e("AirSyncViewModel", "Failed to load notification apps: ${e.message}") + } + } + } + + fun toggleNotificationApp(context: Context, packageName: String, enabled: Boolean) { + viewModelScope.launch(Dispatchers.IO) { + try { + val current = _notificationApps.value.map { + if (it.packageName == packageName) it.copy(isEnabled = enabled) else it + } + _notificationApps.value = current + repository.saveNotificationApps(current) + } catch (e: Exception) { + Log.e("AirSyncViewModel", "Failed to toggle notification app: ${e.message}") + } + } + } + + fun saveAllNotificationApps(context: Context, apps: List) { + viewModelScope.launch(Dispatchers.IO) { + try { + _notificationApps.value = apps + repository.saveNotificationApps(apps) + } catch (e: Exception) { + Log.e("AirSyncViewModel", "Failed to save all notification apps: ${e.message}") + } + } + } + } diff --git a/app/src/main/java/com/sameerasw/airsync/service/AirSyncService.kt b/app/src/main/java/com/sameerasw/airsync/service/AirSyncService.kt index f5d40659..400b434c 100644 --- a/app/src/main/java/com/sameerasw/airsync/service/AirSyncService.kt +++ b/app/src/main/java/com/sameerasw/airsync/service/AirSyncService.kt @@ -17,14 +17,20 @@ 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.DiscoveryMode +import com.sameerasw.airsync.utils.MacDeviceStatusManager +import com.sameerasw.airsync.utils.ShortcutUtil import com.sameerasw.airsync.utils.UDPDiscoveryManager +import com.sameerasw.airsync.utils.WebDavServer import com.sameerasw.airsync.utils.WebSocketUtil import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking /** @@ -38,6 +44,9 @@ class AirSyncService : Service() { private var connectedDeviceName: String? = null private var isScanning = false + private var webDavServer: WebDavServer? = null + private var webDavJob: Job? = null + // Network state tracking private var networkCallback: ConnectivityManager.NetworkCallback? = null @@ -45,7 +54,7 @@ class AirSyncService : Service() { super.onCreate() Log.d(TAG, "AirSyncService created") createNotificationChannel() - com.sameerasw.airsync.utils.MacDeviceStatusManager.startMonitoring(this) + MacDeviceStatusManager.startMonitoring(this) registerNetworkCallback() } @@ -58,7 +67,7 @@ class AirSyncService : Service() { ACTION_START_SYNC -> { connectedDeviceName = intent.getStringExtra(EXTRA_DEVICE_NAME) ?: "Mac" startSync() - com.sameerasw.airsync.utils.ShortcutUtil.refreshShortcuts(this, true) + ShortcutUtil.refreshShortcuts(this, true) } ACTION_STOP_SYNC -> stopSync() @@ -83,7 +92,7 @@ class AirSyncService : Service() { startForeground(NOTIFICATION_ID, buildNotification()) val dataStoreManager = - com.sameerasw.airsync.data.local.DataStoreManager.getInstance(applicationContext) + DataStoreManager.getInstance(applicationContext) val isDiscoveryEnabled = runBlocking { dataStoreManager.getDeviceDiscoveryEnabled().first() } @@ -101,6 +110,39 @@ class AirSyncService : Service() { WebSocketUtil.requestAutoReconnect(this) } + private fun startWebDavServer() { + if (webDavServer == null) { + webDavServer = WebDavServer(this) + } + webDavServer?.start() + } + + private fun stopWebDavServer() { + webDavServer?.stop() + webDavServer = null + } + + private fun monitorWebDavRequirements() { + webDavJob?.cancel() + webDavJob = scope.launch { + val dataStoreManager = DataStoreManager.getInstance(applicationContext) + combine( + dataStoreManager.isFileAccessEnabled(), + dataStoreManager.getLastConnectedDevice() + ) { isEnabled, device -> + Log.d(TAG, "WebDAV flow evaluation: isEnabled=$isEnabled, isPlus=${device?.isPlus}") + isEnabled && device?.isPlus == true + }.collect { shouldStart -> + Log.d(TAG, "WebDAV requirement state updated: shouldStart = $shouldStart") + if (shouldStart) { + startWebDavServer() + } else { + stopWebDavServer() + } + } + } + } + private fun handleAppForeground() { if (isScanning) { Log.d(TAG, "App in foreground, switching to ACTIVE discovery") @@ -118,12 +160,16 @@ class AirSyncService : Service() { } private fun startSync() { + if (!isScanning && connectedDeviceName != null) { + Log.d(TAG, "AirSync foreground service already in sync state, ignoring") + return + } Log.d(TAG, "Starting AirSync foreground service (connected)") isScanning = false startForeground(NOTIFICATION_ID, buildNotification()) val dataStoreManager = - com.sameerasw.airsync.data.local.DataStoreManager.getInstance(applicationContext) + DataStoreManager.getInstance(applicationContext) val isDiscoveryEnabled = runBlocking { dataStoreManager.getDeviceDiscoveryEnabled().first() } @@ -134,11 +180,15 @@ class AirSyncService : Service() { UDPDiscoveryManager.setDiscoveryMode(this, DiscoveryMode.PASSIVE) WakeupService.startService(this) + monitorWebDavRequirements() } private fun stopSync() { Log.d(TAG, "Stopping AirSync foreground service") - com.sameerasw.airsync.utils.ShortcutUtil.refreshShortcuts(this, false) + webDavJob?.cancel() + webDavJob = null + stopWebDavServer() + ShortcutUtil.refreshShortcuts(this, false) UDPDiscoveryManager.stop(this) WakeupService.stopService(this) stopForeground(STOP_FOREGROUND_REMOVE) @@ -155,7 +205,9 @@ class AirSyncService : Service() { networkCallback = object : ConnectivityManager.NetworkCallback() { override fun onAvailable(network: Network) { - Log.d(TAG, "Network available, triggering burst broadcast") + Log.d(TAG, "Network available, triggering burst broadcast and refreshing socket") + // Refresh UDP socket to bind to new network interface + UDPDiscoveryManager.refreshSocket() // When network becomes available, do a burst to announce ourselves if (isScanning) { UDPDiscoveryManager.burstBroadcast(applicationContext) @@ -234,8 +286,9 @@ class AirSyncService : Service() { } } - com.sameerasw.airsync.utils.MacDeviceStatusManager.stopMonitoring() - com.sameerasw.airsync.utils.MacDeviceStatusManager.cleanup(this) + stopWebDavServer() + MacDeviceStatusManager.stopMonitoring() + MacDeviceStatusManager.cleanup(this) scope.coroutineContext.cancel() super.onDestroy() } diff --git a/app/src/main/java/com/sameerasw/airsync/service/AirSyncTileService.kt b/app/src/main/java/com/sameerasw/airsync/service/AirSyncTileService.kt index e7fe404d..f8fa40e1 100644 --- a/app/src/main/java/com/sameerasw/airsync/service/AirSyncTileService.kt +++ b/app/src/main/java/com/sameerasw/airsync/service/AirSyncTileService.kt @@ -202,6 +202,11 @@ class AirSyncTileService : TileService() { "Connected" } } ?: "Connected" + } else if (com.sameerasw.airsync.data.ble.BleGattServer.isAnyAuthenticated() && lastDevice != null) { + // BLE Connected state + state = Tile.STATE_ACTIVE + label = lastDevice.name + subtitle = "Connected BT" } else if (isAuto) { // Auto-reconnect in progress or waiting state = if (isConnecting) Tile.STATE_ACTIVE else Tile.STATE_INACTIVE diff --git a/app/src/main/java/com/sameerasw/airsync/service/CallReceiver.kt b/app/src/main/java/com/sameerasw/airsync/service/CallReceiver.kt index 543e415a..8470591a 100644 --- a/app/src/main/java/com/sameerasw/airsync/service/CallReceiver.kt +++ b/app/src/main/java/com/sameerasw/airsync/service/CallReceiver.kt @@ -5,6 +5,7 @@ import android.content.Context import android.content.Intent import android.telephony.TelephonyManager import android.util.Log +import com.sameerasw.airsync.utils.WebSocketUtil /** * BroadcastReceiver that listens for telephony events (incoming/outgoing calls). @@ -20,6 +21,9 @@ class CallReceiver : BroadcastReceiver() { @Suppress("DEPRECATION") override fun onReceive(context: Context, intent: Intent) { + if (!WebSocketUtil.isConnected()) { + return + } Log.d(TAG, "Broadcast received: ${intent.action}") // Initialize the listener if it's the first time 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 7526353c..6f9ef219 100644 --- a/app/src/main/java/com/sameerasw/airsync/service/MacMediaPlayerService.kt +++ b/app/src/main/java/com/sameerasw/airsync/service/MacMediaPlayerService.kt @@ -27,6 +27,10 @@ class MacMediaPlayerService : Service() { const val EXTRA_ARTIST = "artist" const val EXTRA_IS_PLAYING = "is_playing" const val EXTRA_ALBUM_ART = "album_art" + const val EXTRA_ELAPSED_TIME = "elapsed_time" + const val EXTRA_DURATION = "duration" + const val EXTRA_TIMESTAMP = "timestamp" + const val EXTRA_PLAYBACK_RATE = "playback_rate" private const val TAG = "MacMediaPlayerService" private const val NOTIFICATION_ID = 1001 @@ -39,14 +43,21 @@ class MacMediaPlayerService : Service() { title: String, artist: String, isPlaying: Boolean, - albumArt: Bitmap? + albumArt: Bitmap?, + elapsedTime: Long = 0L, + duration: Long = 0L, + timestamp: String? = null, + playbackRate: Double = 1.0 ) { val intent = Intent(context, MacMediaPlayerService::class.java).apply { action = ACTION_START_MAC_MEDIA putExtra(EXTRA_TITLE, title) putExtra(EXTRA_ARTIST, artist) putExtra(EXTRA_IS_PLAYING, isPlaying) - // Note: Bitmap cannot be passed via Intent, we'll handle it separately + putExtra(EXTRA_ELAPSED_TIME, elapsedTime) + putExtra(EXTRA_DURATION, duration) + putExtra(EXTRA_TIMESTAMP, timestamp) + putExtra(EXTRA_PLAYBACK_RATE, playbackRate) } context.startForegroundService(intent) serviceInstance?.updateAlbumArt(albumArt) @@ -57,13 +68,21 @@ class MacMediaPlayerService : Service() { title: String, artist: String, isPlaying: Boolean, - albumArt: Bitmap? + albumArt: Bitmap?, + elapsedTime: Long = 0L, + duration: Long = 0L, + timestamp: String? = null, + playbackRate: Double = 1.0 ) { val intent = Intent(context, MacMediaPlayerService::class.java).apply { action = ACTION_UPDATE_MAC_MEDIA putExtra(EXTRA_TITLE, title) putExtra(EXTRA_ARTIST, artist) putExtra(EXTRA_IS_PLAYING, isPlaying) + putExtra(EXTRA_ELAPSED_TIME, elapsedTime) + putExtra(EXTRA_DURATION, duration) + putExtra(EXTRA_TIMESTAMP, timestamp) + putExtra(EXTRA_PLAYBACK_RATE, playbackRate) } context.startService(intent) serviceInstance?.updateAlbumArt(albumArt) @@ -84,27 +103,82 @@ class MacMediaPlayerService : Service() { super.onCreate() serviceInstance = this createNotificationChannel() + try { + mediaSession = MediaSessionCompat(this, "MacMediaPlayer").apply { + setCallback(object : MediaSessionCompat.Callback() { + override fun onPlay() { + sendMacMediaControl("play") + updatePlaybackState(true) + } + + override fun onPause() { + sendMacMediaControl("pause") + updatePlaybackState(false) + } + + override fun onSkipToNext() { + sendMacMediaControl("next") + } + + override fun onSkipToPrevious() { + sendMacMediaControl("previous") + } + + override fun onStop() { + sendMacMediaControl("stop") + stopMacMediaSession() + } + }) + } + } catch (e: Exception) { + Log.e(TAG, "Failed to create MediaSession in onCreate: ${e.message}") + } Log.d(TAG, "MacMediaPlayerService created") } override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { - when (intent?.action) { + val action = intent?.action + Log.d(TAG, "onStartCommand: action=$action") + + // Immediately call startForeground to satisfy the Android OS watchdog + val initialTitle = intent?.getStringExtra(EXTRA_TITLE) ?: "" + val initialArtist = intent?.getStringExtra(EXTRA_ARTIST) ?: "" + val initialIsPlaying = intent?.getBooleanExtra(EXTRA_IS_PLAYING, false) ?: false + val notification = createMediaNotification(initialTitle, initialArtist, initialIsPlaying) + try { + startForeground(NOTIFICATION_ID, notification) + } catch (e: Exception) { + Log.e(TAG, "Failed to start foreground in onStartCommand: ${e.message}") + } + + if (action == ACTION_STOP_MAC_MEDIA || action == null) { + stopMacMediaSession() + return START_NOT_STICKY + } + + when (action) { ACTION_START_MAC_MEDIA -> { val title = intent.getStringExtra(EXTRA_TITLE) ?: "" val artist = intent.getStringExtra(EXTRA_ARTIST) ?: "" val isPlaying = intent.getBooleanExtra(EXTRA_IS_PLAYING, false) - startMacMediaSession(title, artist, isPlaying) + val elapsedTime = intent.getLongExtra(EXTRA_ELAPSED_TIME, 0L) + val duration = intent.getLongExtra(EXTRA_DURATION, 0L) + val timestamp = intent.getStringExtra(EXTRA_TIMESTAMP) + val playbackRate = intent.getDoubleExtra(EXTRA_PLAYBACK_RATE, 1.0) + + startMacMediaSession(title, artist, isPlaying, elapsedTime, duration, timestamp, playbackRate) } ACTION_UPDATE_MAC_MEDIA -> { val title = intent.getStringExtra(EXTRA_TITLE) ?: "" val artist = intent.getStringExtra(EXTRA_ARTIST) ?: "" val isPlaying = intent.getBooleanExtra(EXTRA_IS_PLAYING, false) - updateMacMediaSession(title, artist, isPlaying) - } - - ACTION_STOP_MAC_MEDIA -> { - stopMacMediaSession() + val elapsedTime = intent.getLongExtra(EXTRA_ELAPSED_TIME, 0L) + val duration = intent.getLongExtra(EXTRA_DURATION, 0L) + val timestamp = intent.getStringExtra(EXTRA_TIMESTAMP) + val playbackRate = intent.getDoubleExtra(EXTRA_PLAYBACK_RATE, 1.0) + + updateMacMediaSession(title, artist, isPlaying, elapsedTime, duration, timestamp, playbackRate) } // Handle media control actions from notification buttons "MAC_MEDIA_play" -> { @@ -150,39 +224,18 @@ class MacMediaPlayerService : Service() { notificationManager.createNotificationChannel(channel) } - private fun startMacMediaSession(title: String, artist: String, isPlaying: Boolean) { + private fun startMacMediaSession( + title: String, + artist: String, + isPlaying: Boolean, + elapsedTime: Long = 0L, + duration: Long = 0L, + timestamp: String? = null, + playbackRate: Double = 1.0 + ) { try { - if (mediaSession == null) { - mediaSession = MediaSessionCompat(this, "MacMediaPlayer").apply { - setCallback(object : MediaSessionCompat.Callback() { - override fun onPlay() { - sendMacMediaControl("play") - updatePlaybackState(true) - } - - override fun onPause() { - sendMacMediaControl("pause") - updatePlaybackState(false) - } - - override fun onSkipToNext() { - sendMacMediaControl("next") - } - - override fun onSkipToPrevious() { - sendMacMediaControl("previous") - } - - override fun onStop() { - sendMacMediaControl("stop") - stopMacMediaSession() - } - }) - } - } - - updateMediaMetadata(title, artist) - updatePlaybackState(isPlaying) + updateMediaMetadata(title, artist, duration) + updatePlaybackState(isPlaying, elapsedTime, timestamp, playbackRate) mediaSession?.isActive = true val notification = createMediaNotification(title, artist, isPlaying) @@ -194,10 +247,18 @@ class MacMediaPlayerService : Service() { } } - private fun updateMacMediaSession(title: String, artist: String, isPlaying: Boolean) { + private fun updateMacMediaSession( + title: String, + artist: String, + isPlaying: Boolean, + elapsedTime: Long = 0L, + duration: Long = 0L, + timestamp: String? = null, + playbackRate: Double = 1.0 + ) { try { - updateMediaMetadata(title, artist) - updatePlaybackState(isPlaying) + updateMediaMetadata(title, artist, duration) + updatePlaybackState(isPlaying, elapsedTime, timestamp, playbackRate) val notification = createMediaNotification(title, artist, isPlaying) val notificationManager = getSystemService(NotificationManager::class.java) @@ -209,12 +270,12 @@ class MacMediaPlayerService : Service() { } } - private fun updateMediaMetadata(title: String, artist: String) { + private fun updateMediaMetadata(title: String, artist: String, duration: Long = 0L) { val metadataBuilder = MediaMetadataCompat.Builder() .putString(MediaMetadataCompat.METADATA_KEY_TITLE, title) .putString(MediaMetadataCompat.METADATA_KEY_ARTIST, artist) .putString(MediaMetadataCompat.METADATA_KEY_ALBUM, "Playing on Mac") - .putLong(MediaMetadataCompat.METADATA_KEY_DURATION, 180000) + .putLong(MediaMetadataCompat.METADATA_KEY_DURATION, duration) currentAlbumArt?.let { bitmap -> metadataBuilder.putBitmap(MediaMetadataCompat.METADATA_KEY_ALBUM_ART, bitmap) @@ -223,7 +284,12 @@ class MacMediaPlayerService : Service() { mediaSession?.setMetadata(metadataBuilder.build()) } - private fun updatePlaybackState(isPlaying: Boolean) { + private fun updatePlaybackState( + isPlaying: Boolean, + elapsedTime: Long = -1L, + timestamp: String? = null, + playbackRate: Double = 1.0 + ) { val state = if (isPlaying) PlaybackStateCompat.STATE_PLAYING else PlaybackStateCompat.STATE_PAUSED val actions = PlaybackStateCompat.ACTION_PLAY_PAUSE or @@ -231,9 +297,24 @@ class MacMediaPlayerService : Service() { PlaybackStateCompat.ACTION_SKIP_TO_PREVIOUS or PlaybackStateCompat.ACTION_STOP + // Parse ISO8601 timestamp to calculate elapsed time since reporting + var position = if (elapsedTime >= 0) elapsedTime else (mediaSession?.controller?.playbackState?.position ?: 0L) + if (isPlaying && !timestamp.isNullOrEmpty()) { + try { + val reportedAt = java.time.Instant.parse(timestamp).toEpochMilli() + val now = System.currentTimeMillis() + val diff = now - reportedAt + if (diff > 0) { + position += (diff * playbackRate).toLong() + } + } catch (e: Exception) { + Log.w(TAG, "Failed to parse timestamp: $timestamp") + } + } + mediaSession?.setPlaybackState( PlaybackStateCompat.Builder() - .setState(state, 30000L, if (isPlaying) 1.0f else 0.0f) + .setState(state, position, playbackRate.toFloat()) .setActions(actions) .build() ) @@ -346,7 +427,11 @@ class MacMediaPlayerService : Service() { val artist = metadata.getString(MediaMetadataCompat.METADATA_KEY_ARTIST) ?: "" val isPlaying = it.controller.playbackState?.state == PlaybackStateCompat.STATE_PLAYING - updateMacMediaSession(title, artist, isPlaying) + val duration = metadata.getLong(MediaMetadataCompat.METADATA_KEY_DURATION) + val position = it.controller.playbackState?.position ?: 0L + val speed = it.controller.playbackState?.playbackSpeed?.toDouble() ?: 1.0 + + updateMacMediaSession(title, artist, isPlaying, position, duration, null, speed) } } } diff --git a/app/src/main/java/com/sameerasw/airsync/service/MediaNotificationListener.kt b/app/src/main/java/com/sameerasw/airsync/service/MediaNotificationListener.kt index 0ae06ea9..c0f22a71 100644 --- a/app/src/main/java/com/sameerasw/airsync/service/MediaNotificationListener.kt +++ b/app/src/main/java/com/sameerasw/airsync/service/MediaNotificationListener.kt @@ -86,7 +86,7 @@ class MediaNotificationListener : NotificationListenerService() { fun getMediaInfo(context: Context): MediaInfo { // Respect global toggle; if disabled, return empty media if (!isNowPlayingEnabled) { - return MediaInfo(false, "", "", null, "none") + return MediaInfo(false, "", "", null, null, 0L, 0L, 0L, false, "none") } return try { val mediaSessionManager = @@ -119,6 +119,14 @@ class MediaNotificationListener : NotificationListenerService() { val title = metadata?.getString(MediaMetadata.METADATA_KEY_TITLE) ?: "" val artist = metadata?.getString(MediaMetadata.METADATA_KEY_ARTIST) ?: "" val isPlaying = playbackState?.state == PlaybackState.STATE_PLAYING + val durationMs = metadata?.getLong(MediaMetadata.METADATA_KEY_DURATION) ?: 0L + val positionMs = playbackState?.position ?: 0L + val positionTimestampMs = System.currentTimeMillis() + val isBuffering = when (playbackState?.state) { + PlaybackState.STATE_BUFFERING, + PlaybackState.STATE_CONNECTING -> true + else -> false + } val albumArtBitmap = metadata?.getBitmap(MediaMetadata.METADATA_KEY_ALBUM_ART) @@ -131,6 +139,18 @@ class MediaNotificationListener : NotificationListenerService() { Base64.encodeToString(outputStream.toByteArray(), Base64.NO_WRAP) } + val albumArtLiteBase64 = albumArtBitmap?.let { + try { + val outputStream = ByteArrayOutputStream() + // Scale down to 80x80 and lower quality for BLE + val scaled = Bitmap.createScaledBitmap(it, 80, 80, true) + scaled.compress(Bitmap.CompressFormat.JPEG, 30, outputStream) + Base64.encodeToString(outputStream.toByteArray(), Base64.NO_WRAP) + } catch (e: Exception) { + null + } + } + // Log.d(TAG, "Media session - Title: $title, Artist: $artist, Playing: $isPlaying, State: ${playbackState?.state}") @@ -153,6 +173,11 @@ class MediaNotificationListener : NotificationListenerService() { title = title, artist = artist, albumArt = albumArtBase64, + albumArtLite = albumArtLiteBase64, + durationMs = durationMs, + positionMs = positionMs, + positionTimestampMs = positionTimestampMs, + isBuffering = isBuffering, likeStatus = likeStatus ) } @@ -168,10 +193,10 @@ class MediaNotificationListener : NotificationListenerService() { } // Log.d(TAG, "No media info found") - MediaInfo(false, "", "", null, "none") + MediaInfo(false, "", "", null, null, 0L, 0L, 0L, false, "none") } catch (e: Exception) { Log.e(TAG, "Error getting media info: ${e.message}") - MediaInfo(false, "", "", null, "none") + MediaInfo(false, "", "", null, null, 0L, 0L, 0L, false, "none") } } @@ -427,6 +452,9 @@ class MediaNotificationListener : NotificationListenerService() { override fun onNotificationPosted(sbn: StatusBarNotification?) { super.onNotificationPosted(sbn) + if (!WebSocketUtil.isConnected()) { + return + } sbn?.let { notification -> Log.d( TAG, @@ -460,12 +488,18 @@ class MediaNotificationListener : NotificationListenerService() { override fun onNotificationRemoved(sbn: StatusBarNotification?) { super.onNotificationRemoved(sbn) + if (!WebSocketUtil.isConnected()) { + return + } if (sbn != null) handleNotificationRemoval(sbn) } // API level variants call the same handler to ensure we catch swipe-away removals override fun onNotificationRemoved(sbn: StatusBarNotification, rankingMap: RankingMap) { super.onNotificationRemoved(sbn, rankingMap) + if (!WebSocketUtil.isConnected()) { + return + } handleNotificationRemoval(sbn) } @@ -475,6 +509,9 @@ class MediaNotificationListener : NotificationListenerService() { reason: Int ) { super.onNotificationRemoved(sbn, rankingMap, reason) + if (!WebSocketUtil.isConnected()) { + return + } handleNotificationRemoval(sbn) } @@ -499,41 +536,35 @@ class MediaNotificationListener : NotificationListenerService() { ) } - // Send dismissal update to Mac for real removals + // Sync dismissal to Mac serviceScope.launch { try { - val id = NotificationDismissalUtil.getIdForSbn(sbn) - if (id.isNullOrEmpty()) { - // Skip if we cannot map to a known id - return@launch - } + val id = NotificationDismissalUtil.getIdForSbn(sbn) ?: sbn.key + // If this dismissal was initiated by our own dismiss request, skip echo val wasSuppressed = NotificationDismissalUtil.consumeSuppressed(id) if (wasSuppressed) { - // Clean local caches and ignore + Log.d(TAG, "Dismissal for $id was suppressed (originated from Mac)") NotificationDismissalUtil.removeFromCaches(id) return@launch } - // Build and send update - if (WebSocketUtil.isConnected()) { - val update = JsonUtil.toSingleLine( - JsonUtil.createNotificationUpdateJson( - id, - dismissed = true, - action = "dismiss" - ) + // Build and send update via WebSocket + val updateJson = JsonUtil.toSingleLine( + JsonUtil.createNotificationUpdateJson( + id, + dismissed = true, + action = "dismiss" ) - val sent = WebSocketUtil.sendMessage(update) - Log.d(TAG, "Sent notificationUpdate for $id: $sent") - } else { - Log.d(TAG, "WebSocket not connected; skipping notificationUpdate for $id") - } + ) + WebSocketUtil.sendMessage(updateJson) + + Log.d(TAG, "Sent notification removal sync for $id") // Remove from caches since it's gone now NotificationDismissalUtil.removeFromCaches(id) } catch (e: Exception) { - Log.e(TAG, "Error sending notificationUpdate: ${e.message}") + Log.e(TAG, "Error syncing notification removal: ${e.message}") } } } @@ -655,19 +686,15 @@ class MediaNotificationListener : NotificationListenerService() { Log.d(TAG, "Preparing to send notification: $notificationJson") - if (WebSocketUtil.isConnected()) { - Log.d(TAG, "Sending notification via WebSocket") - val success = WebSocketUtil.sendMessage(notificationJson) - if (success) { - Log.d( - TAG, - "Notification sent successfully via existing WebSocket connection" - ) - } else { - Log.e(TAG, "Failed to send notification via WebSocket") - } + Log.d(TAG, "Sending notification via WS/BLE") + val success = WebSocketUtil.sendMessage(notificationJson) + if (success) { + Log.d( + TAG, + "Notification sent successfully" + ) } else { - Log.d(TAG, "WebSocket not connected, skipping notification sync") + Log.e(TAG, "Failed to send notification") } } else { Log.d(TAG, "Skipping empty notification from ${sbn.packageName}") diff --git a/app/src/main/java/com/sameerasw/airsync/utils/CallControlUtil.kt b/app/src/main/java/com/sameerasw/airsync/utils/CallControlUtil.kt new file mode 100644 index 00000000..c76da93e --- /dev/null +++ b/app/src/main/java/com/sameerasw/airsync/utils/CallControlUtil.kt @@ -0,0 +1,101 @@ +package com.sameerasw.airsync.utils + +import android.Manifest +import android.content.Context +import android.content.pm.PackageManager +import android.media.AudioManager +import android.os.Build +import android.telecom.TelecomManager +import android.view.KeyEvent +import androidx.core.content.ContextCompat +import android.util.Log + +object CallControlUtil { + private const val TAG = "CallControlUtil" + + /** + * Programmatically accept an incoming call. + * Uses TelecomManager on API 26+ if permission is granted, falling back to KEYCODE_HEADSETHOOK emulation. + */ + fun acceptCall(context: Context) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val hasPermission = ContextCompat.checkSelfPermission( + context, + Manifest.permission.ANSWER_PHONE_CALLS + ) == PackageManager.PERMISSION_GRANTED + + if (hasPermission) { + try { + val telecomManager = context.getSystemService(Context.TELECOM_SERVICE) as? TelecomManager + if (telecomManager != null) { + Log.d(TAG, "Accepting ringing call via TelecomManager") + telecomManager.acceptRingingCall() + return + } + } catch (e: Exception) { + Log.e(TAG, "Failed to accept ringing call via TelecomManager, falling back", e) + } + } else { + Log.w(TAG, "ANSWER_PHONE_CALLS permission not granted, falling back to media key hook") + } + } + + // Fallback: Dispatch HEADSETHOOK media key event + emulateHeadsetHookClick(context) + } + + /** + * Programmatically end or decline a call. + * Uses TelecomManager on API 28+ if permission is granted, falling back to KEYCODE_HEADSETHOOK emulation. + */ + fun endCall(context: Context) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + val hasPermission = ContextCompat.checkSelfPermission( + context, + Manifest.permission.ANSWER_PHONE_CALLS + ) == PackageManager.PERMISSION_GRANTED + + if (hasPermission) { + try { + val telecomManager = context.getSystemService(Context.TELECOM_SERVICE) as? TelecomManager + if (telecomManager != null) { + Log.d(TAG, "Ending/declining call via TelecomManager") + val success = telecomManager.endCall() + Log.d(TAG, "TelecomManager.endCall returned: $success") + if (success) { + return + } + } + } catch (e: Exception) { + Log.e(TAG, "Failed to end call via TelecomManager, falling back", e) + } + } else { + Log.w(TAG, "ANSWER_PHONE_CALLS permission not granted, falling back to media key hook") + } + } + + // Fallback: Dispatch HEADSETHOOK media key event + emulateHeadsetHookClick(context) + } + + /** + * Emulates clicking a hardware headset button (down and up events for KEYCODE_HEADSETHOOK). + * This acts as a reliable system-wide fallback to answer/end calls. + */ + private fun emulateHeadsetHookClick(context: Context) { + try { + val audioManager = context.getSystemService(Context.AUDIO_SERVICE) as? AudioManager + if (audioManager != null) { + Log.d(TAG, "Dispatching KEYCODE_HEADSETHOOK click to AudioManager") + val downEvent = KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_HEADSETHOOK) + val upEvent = KeyEvent(KeyEvent.ACTION_UP, KeyEvent.KEYCODE_HEADSETHOOK) + audioManager.dispatchMediaKeyEvent(downEvent) + audioManager.dispatchMediaKeyEvent(upEvent) + } else { + Log.e(TAG, "AudioManager not available, cannot emulate headset hook") + } + } catch (e: Exception) { + Log.e(TAG, "Error emulating headset hook click", e) + } + } +} diff --git a/app/src/main/java/com/sameerasw/airsync/utils/DeviceInfoUtil.kt b/app/src/main/java/com/sameerasw/airsync/utils/DeviceInfoUtil.kt index 8aac4a56..49d006cd 100644 --- a/app/src/main/java/com/sameerasw/airsync/utils/DeviceInfoUtil.kt +++ b/app/src/main/java/com/sameerasw/airsync/utils/DeviceInfoUtil.kt @@ -151,6 +151,11 @@ object DeviceInfoUtil { volume = volumePercent, isMuted = isMuted, albumArt = null, + albumArtLite = null, + durationMs = 0L, + positionMs = 0L, + positionTimestampMs = 0L, + isBuffering = false, likeStatus = "none" ) } @@ -166,11 +171,16 @@ object DeviceInfoUtil { volume = volumePercent, isMuted = isMuted, albumArt = mediaInfo.albumArt, + albumArtLite = mediaInfo.albumArtLite, + durationMs = mediaInfo.durationMs, + positionMs = mediaInfo.positionMs, + positionTimestampMs = mediaInfo.positionTimestampMs, + isBuffering = mediaInfo.isBuffering, likeStatus = mediaInfo.likeStatus ) } catch (e: Exception) { Log.e("DeviceInfoUtil", "Error getting audio info: ${e.message}") - AudioInfo(false, "", "", 0, true, null, "none") + AudioInfo(false, "", "", 0, true, null, null, 0L, 0L, 0L, false, "none") } } @@ -188,6 +198,11 @@ object DeviceInfoUtil { volume = audioInfo.volume, isMuted = audioInfo.isMuted, albumArt = audioInfo.albumArt, + albumArtLite = audioInfo.albumArtLite, + duration = audioInfo.durationMs, + position = audioInfo.positionMs, + positionTimestamp = audioInfo.positionTimestampMs, + isBuffering = audioInfo.isBuffering, likeStatus = audioInfo.likeStatus ) } @@ -212,4 +227,4 @@ object DeviceInfoUtil { val powerManager = context.getSystemService(Context.POWER_SERVICE) as? android.os.PowerManager return powerManager?.isPowerSaveMode == true } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/sameerasw/airsync/utils/JsonUtil.kt b/app/src/main/java/com/sameerasw/airsync/utils/JsonUtil.kt index e6536f1c..0ee5ca05 100644 --- a/app/src/main/java/com/sameerasw/airsync/utils/JsonUtil.kt +++ b/app/src/main/java/com/sameerasw/airsync/utils/JsonUtil.kt @@ -61,6 +61,7 @@ object JsonUtil { version: String, wallpaperBase64: String?, adbPorts: List, + bleAuthToken: String? = null, targetIpAddress: String? = null ): String { val wallpaperJson = if (wallpaperBase64 != null) { @@ -68,10 +69,15 @@ object JsonUtil { } else { "" } + val bleTokenJson = if (bleAuthToken != null) { + ""","bleAuthToken":"$bleAuthToken"""" + } else { + "" + } val portsJson = adbPorts.joinToString(",") { "\"$it\"" } val targetIpJson = if (targetIpAddress != null) """, "targetIpAddress": "$targetIpAddress" """ else "" - return """{"type":"device","data":{"id":"$id","name":"$name","ipAddress":"$ipAddress","port":$port,"version":"$version","adbPorts":[$portsJson]$wallpaperJson$targetIpJson}}""" + return """{"type":"device","data":{"id":"$id","name":"$name","ipAddress":"$ipAddress","port":$port,"version":"$version","adbPorts":[$portsJson]$wallpaperJson$bleTokenJson$targetIpJson}}""" } /** @@ -150,10 +156,16 @@ object JsonUtil { volume: Int, isMuted: Boolean, albumArt: String?, + albumArtLite: String? = null, + duration: Long = 0L, + position: Long = 0L, + positionTimestamp: Long = 0L, + isBuffering: Boolean = false, likeStatus: String ): String { val albumArtJson = if (albumArt != null) ",\"albumArt\":\"$albumArt\"" else "" - return """{"type":"status","data":{"battery":{"level":$batteryLevel,"isCharging":$isCharging},"isPaired":$isPaired,"music":{"isPlaying":$isPlaying,"title":"$title","artist":"$artist","volume":$volume,"isMuted":$isMuted$albumArtJson,"likeStatus":"$likeStatus"}}}""" + val albumArtLiteJson = if (albumArtLite != null) ",\"albumArtLite\":\"$albumArtLite\"" else "" + return """{"type":"status","data":{"battery":{"level":$batteryLevel,"isCharging":$isCharging},"isPaired":$isPaired,"music":{"isPlaying":$isPlaying,"title":"$title","artist":"$artist","volume":$volume,"isMuted":$isMuted$albumArtJson$albumArtLiteJson,"duration":$duration,"position":$position,"positionTimestamp":$positionTimestamp,"isBuffering":$isBuffering,"likeStatus":"$likeStatus"}}}""" } /** @@ -258,4 +270,4 @@ object JsonUtil { ) }"}}""" } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/sameerasw/airsync/utils/MacDeviceStatusManager.kt b/app/src/main/java/com/sameerasw/airsync/utils/MacDeviceStatusManager.kt index fb930087..65cacb01 100644 --- a/app/src/main/java/com/sameerasw/airsync/utils/MacDeviceStatusManager.kt +++ b/app/src/main/java/com/sameerasw/airsync/utils/MacDeviceStatusManager.kt @@ -6,6 +6,7 @@ import android.graphics.BitmapFactory import android.util.Base64 import android.util.Log import com.sameerasw.airsync.data.local.DataStoreManager +import com.sameerasw.airsync.data.ble.BleGattServer import com.sameerasw.airsync.domain.model.MacBattery import com.sameerasw.airsync.domain.model.MacDeviceStatus import com.sameerasw.airsync.domain.model.MacMusicInfo @@ -27,8 +28,84 @@ object MacDeviceStatusManager { private val _albumArt = MutableStateFlow(null) val albumArt: StateFlow = _albumArt.asStateFlow() + fun updateBatteryStatus(context: Context, level: Int, isCharging: Boolean) { + val current = _macDeviceStatus.value + updateStatus( + context = context, + name = current?.name ?: "Unknown", + batteryLevel = level, + isCharging = isCharging, + isPaired = current?.isPaired ?: true, + isPlaying = current?.music?.isPlaying ?: false, + title = current?.music?.title ?: "", + artist = current?.music?.artist ?: "", + volume = current?.music?.volume ?: 0, + isMuted = current?.music?.isMuted ?: false, + albumArt = null, // keep current + likeStatus = current?.music?.likeStatus ?: "none", + elapsedTime = current?.music?.elapsedTime ?: 0L, + duration = current?.music?.duration ?: 0L, + timestamp = current?.music?.timestamp, + playbackRate = current?.music?.playbackRate ?: 1.0 + ) + } + + fun updateMacStatus(context: Context, name: String) { + val current = _macDeviceStatus.value + updateStatus( + context = context, + name = name, + batteryLevel = current?.battery?.level ?: -1, + isCharging = current?.battery?.isCharging ?: false, + isPaired = current?.isPaired ?: true, + isPlaying = current?.music?.isPlaying ?: false, + title = current?.music?.title ?: "", + artist = current?.music?.artist ?: "", + volume = current?.music?.volume ?: 0, + isMuted = current?.music?.isMuted ?: false, + albumArt = null, // keep current + likeStatus = current?.music?.likeStatus ?: "none", + elapsedTime = current?.music?.elapsedTime ?: 0L, + duration = current?.music?.duration ?: 0L, + timestamp = current?.music?.timestamp, + playbackRate = current?.music?.playbackRate ?: 1.0 + ) + } + + fun updateMusicStatus( + context: Context, + isPlaying: Boolean, + title: String, + artist: String, + volume: Int, + isMuted: Boolean, + likeStatus: String, + albumArt: String? = null + ) { + val current = _macDeviceStatus.value + updateStatus( + context = context, + name = current?.name ?: "Unknown", + batteryLevel = current?.battery?.level ?: -1, + isCharging = current?.battery?.isCharging ?: false, + isPaired = current?.isPaired ?: true, + isPlaying = isPlaying, + title = title, + artist = artist, + volume = volume, + isMuted = isMuted, + albumArt = albumArt, + likeStatus = likeStatus, + elapsedTime = current?.music?.elapsedTime ?: 0L, + duration = current?.music?.duration ?: 0L, + timestamp = current?.music?.timestamp, + playbackRate = current?.music?.playbackRate ?: 1.0 + ) + } + fun updateStatus( context: Context, + name: String, batteryLevel: Int, isCharging: Boolean, isPaired: Boolean, @@ -38,7 +115,11 @@ object MacDeviceStatusManager { volume: Int, isMuted: Boolean, albumArt: String?, - likeStatus: String + likeStatus: String, + elapsedTime: Long = 0L, + duration: Long = 0L, + timestamp: String? = null, + playbackRate: Double = 1.0 ) { try { val effectiveAlbumArt = albumArt ?: _macDeviceStatus.value?.music?.albumArt ?: "" @@ -51,10 +132,15 @@ object MacDeviceStatusManager { volume = volume, isMuted = isMuted, albumArt = effectiveAlbumArt, - likeStatus = likeStatus + likeStatus = likeStatus, + elapsedTime = elapsedTime, + duration = duration, + timestamp = timestamp, + playbackRate = playbackRate ) val status = MacDeviceStatus( + name = name, battery = macBattery, isPaired = isPaired, music = macMusicInfo @@ -73,11 +159,21 @@ object MacDeviceStatusManager { CoroutineScope(Dispatchers.IO).launch { val ds = DataStoreManager(context) val isMediaControlsEnabled = ds.getMacMediaControlsEnabled().first() - val isConnected = WebSocketUtil.isConnected() + val isConnected = WebSocketUtil.isConnected() || WebSocketUtil.isConnecting() || BleGattServer.isAnyAuthenticated() val isEssentialsEnabled = ds.getEssentialsConnectionEnabled().first() if (isConnected && isMediaControlsEnabled && (title.isNotEmpty() || artist.isNotEmpty() || isPlaying)) { - MacMediaPlayerService.startMacMedia(context, title, artist, isPlaying, bitmap) + MacMediaPlayerService.startMacMedia( + context, + title, + artist, + isPlaying, + bitmap, + elapsedTime, + duration, + timestamp, + playbackRate + ) Log.d(TAG, "Started/Updated Mac media player service") } else { MacMediaPlayerService.stopMacMedia(context) @@ -175,7 +271,7 @@ object MacDeviceStatusManager { CoroutineScope(Dispatchers.IO).launch { try { // Check current state - val isConnected = WebSocketUtil.isConnected() + val isConnected = WebSocketUtil.isConnected() || BleGattServer.isAnyAuthenticated() val currentStatus = _macDeviceStatus.value if (isConnected && currentStatus != null) { diff --git a/app/src/main/java/com/sameerasw/airsync/utils/MediaControlUtil.kt b/app/src/main/java/com/sameerasw/airsync/utils/MediaControlUtil.kt index aaa0b7f6..6fc55738 100644 --- a/app/src/main/java/com/sameerasw/airsync/utils/MediaControlUtil.kt +++ b/app/src/main/java/com/sameerasw/airsync/utils/MediaControlUtil.kt @@ -100,6 +100,26 @@ object MediaControlUtil { } } + /** + * Seek active media playback to an absolute position in milliseconds. + */ + fun seekTo(context: Context, positionMs: Long): Boolean { + return try { + val controller = getActiveMediaController(context) + if (controller != null) { + controller.transportControls.seekTo(positionMs.coerceAtLeast(0L)) + Log.d(TAG, "Seeked media to ${positionMs}ms") + true + } else { + Log.w(TAG, "No active media controller for seek") + false + } + } catch (e: Exception) { + Log.e(TAG, "Error in seekTo: ${e.message}") + false + } + } + /** * Toggle like status by invoking the Like/Unlike action in the active media notification. */ diff --git a/app/src/main/java/com/sameerasw/airsync/utils/NetworkMonitor.kt b/app/src/main/java/com/sameerasw/airsync/utils/NetworkMonitor.kt index 789d135d..71f5dfe2 100644 --- a/app/src/main/java/com/sameerasw/airsync/utils/NetworkMonitor.kt +++ b/app/src/main/java/com/sameerasw/airsync/utils/NetworkMonitor.kt @@ -22,6 +22,8 @@ object NetworkMonitor { val connectivityManager = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager + var lastNetworkInfo: NetworkInfo? = null + fun getCurrentNetworkInfo(): NetworkInfo { val activeNetwork = connectivityManager.activeNetwork val networkCapabilities = connectivityManager.getNetworkCapabilities(activeNetwork) @@ -35,27 +37,39 @@ object NetworkMonitor { return NetworkInfo(isConnected, isWifi, ipAddress) } + fun sendIfChanged() { + val info = getCurrentNetworkInfo() + if (info != lastNetworkInfo) { + lastNetworkInfo = info + trySend(info) + } + } + // Send initial state - trySend(getCurrentNetworkInfo()) + sendIfChanged() // Use NetworkCallback for newer versions val networkCallback = object : ConnectivityManager.NetworkCallback() { override fun onAvailable(network: Network) { Log.d(TAG, "Network available: $network") - trySend(getCurrentNetworkInfo()) + sendIfChanged() } override fun onLost(network: Network) { Log.d(TAG, "Network lost: $network") - trySend(getCurrentNetworkInfo()) + sendIfChanged() } override fun onCapabilitiesChanged( network: Network, networkCapabilities: NetworkCapabilities ) { - Log.d(TAG, "Network capabilities changed: $network") - trySend(getCurrentNetworkInfo()) + val info = getCurrentNetworkInfo() + if (info != lastNetworkInfo) { + Log.d(TAG, "Network capabilities changed and network info updated: $network") + lastNetworkInfo = info + trySend(info) + } } } diff --git a/app/src/main/java/com/sameerasw/airsync/utils/PermissionUtil.kt b/app/src/main/java/com/sameerasw/airsync/utils/PermissionUtil.kt index ecd8b1f1..49da76d9 100644 --- a/app/src/main/java/com/sameerasw/airsync/utils/PermissionUtil.kt +++ b/app/src/main/java/com/sameerasw/airsync/utils/PermissionUtil.kt @@ -102,6 +102,20 @@ object PermissionUtil { } } + /** + * Check if ACCESS_LOCAL_NETWORK permission is granted (Android 17+) + */ + fun isLocalNetworkPermissionGranted(context: Context): Boolean { + return if (Build.VERSION.SDK_INT >= 37) { + ContextCompat.checkSelfPermission( + context, + "android.permission.ACCESS_LOCAL_NETWORK" + ) == PackageManager.PERMISSION_GRANTED + } else { + true + } + } + /** * Check if notification permissions are required for this Android version */ @@ -187,6 +201,18 @@ object PermissionUtil { missing.add("Phone Access") } + if (!isBluetoothPermissionsGranted(context)) { + missing.add("Bluetooth Access") + } + + if (Build.VERSION.SDK_INT >= 37 && !isLocalNetworkPermissionGranted(context)) { + missing.add("Local Network Access") + } + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && !isAnswerCallsPermissionGranted(context)) { + missing.add("Answer Calls") + } + return missing } @@ -240,6 +266,18 @@ object PermissionUtil { optional.add("Phone Access") } + if (!isBluetoothPermissionsGranted(context)) { + optional.add("Bluetooth Access") + } + + if (Build.VERSION.SDK_INT >= 37 && !isLocalNetworkPermissionGranted(context)) { + optional.add("Local Network Access") + } + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && !isAnswerCallsPermissionGranted(context)) { + optional.add("Answer Calls") + } + return optional } @@ -272,4 +310,31 @@ object PermissionUtil { Manifest.permission.READ_PHONE_STATE ) == PackageManager.PERMISSION_GRANTED } + + /** + * Check if Bluetooth permissions are granted (Connect and Advertise/Scan on Android 12+) + */ + fun isBluetoothPermissionsGranted(context: Context): Boolean { + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + ContextCompat.checkSelfPermission(context, Manifest.permission.BLUETOOTH_CONNECT) == PackageManager.PERMISSION_GRANTED && + ContextCompat.checkSelfPermission(context, Manifest.permission.BLUETOOTH_ADVERTISE) == PackageManager.PERMISSION_GRANTED + } else { + // On older versions, manifest permissions are enough + true + } + } + + /** + * Check if ANSWER_PHONE_CALLS permission is granted + */ + fun isAnswerCallsPermissionGranted(context: Context): Boolean { + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + ContextCompat.checkSelfPermission( + context, + Manifest.permission.ANSWER_PHONE_CALLS + ) == PackageManager.PERMISSION_GRANTED + } else { + true + } + } } diff --git a/app/src/main/java/com/sameerasw/airsync/utils/SyncManager.kt b/app/src/main/java/com/sameerasw/airsync/utils/SyncManager.kt index 3da42518..cb414316 100644 --- a/app/src/main/java/com/sameerasw/airsync/utils/SyncManager.kt +++ b/app/src/main/java/com/sameerasw/airsync/utils/SyncManager.kt @@ -16,7 +16,7 @@ import java.util.concurrent.atomic.AtomicBoolean object SyncManager { private const val TAG = "SyncManager" - private const val BATTERY_SYNC_INTERVAL = 20_000L // 20 seconds + private const val BATTERY_SYNC_INTERVAL = 10_000L // 10 seconds private const val MAX_DAILY_ICON_SYNCS = 3 private var syncJob: Job? = null @@ -42,8 +42,14 @@ object SyncManager { while (isActive && isSyncing.get()) { try { - // Check if WebSocket is connected and sync is enabled - if (WebSocketUtil.isConnected()) { + // Heartbeat: Sync battery over BLE if authenticated and WS not connected to keep Mac connection alive + if (com.sameerasw.airsync.data.ble.BleGattServer.isAnyAuthenticated() && !WebSocketUtil.isConnected()) { + val currentBattery = DeviceInfoUtil.getBatteryInfo(context) + com.sameerasw.airsync.data.ble.BleTransportBridge.sendBatteryStatus(currentBattery) + } + + // Check if sync is needed (either via WebSocket or BLE) + if (WebSocketUtil.isConnected() || com.sameerasw.airsync.data.ble.BleGattServer.isAnyAuthenticated()) { val dataStoreManager = DataStoreManager(context) val isSyncEnabled = dataStoreManager.getNotificationSyncEnabled().first() @@ -72,6 +78,9 @@ object SyncManager { } fun checkAndSyncDeviceStatus(context: Context, forceSync: Boolean = false) { + if (!WebSocketUtil.isConnected()) { + return + } CoroutineScope(Dispatchers.IO).launch { try { val dataStoreManager = DataStoreManager(context) @@ -89,6 +98,9 @@ object SyncManager { last.artist != currentAudio.artist || last.volume != currentAudio.volume || last.isMuted != currentAudio.isMuted || + last.isBuffering != currentAudio.isBuffering || + last.durationMs != currentAudio.durationMs || + kotlin.math.abs(last.positionMs - currentAudio.positionMs) >= 5_000L || last.likeStatus != currentAudio.likeStatus ) { shouldSync = true @@ -110,17 +122,17 @@ object SyncManager { shouldSync = true // First time } - if (shouldSync && WebSocketUtil.isConnected()) { + if (shouldSync) { val statusJson = DeviceInfoUtil.generateDeviceStatusJson(context) + // sendMessage handles both WebSocket and BLE fallback internally val success = WebSocketUtil.sendMessage(statusJson) if (success) { - // Log.d(TAG, "Device status synced successfully") lastAudioInfo = currentAudio lastBatteryInfo = currentBattery lastVolume = currentAudio.volume } else { - Log.w(TAG, "Failed to sync device status") + Log.w(TAG, "Failed to sync device status (WS/BLE)") } } @@ -189,17 +201,22 @@ object SyncManager { Log.d(TAG, "Discovered ADB ports: $adbPorts") val deviceId = DeviceInfoUtil.getDeviceId(context) + val symmetricKey = dataStoreManager.getLastConnectedDevice().first()?.symmetricKey ?: "" + val bleAuthToken = com.sameerasw.airsync.data.ble.BleTransportBridge.deriveAuthToken(symmetricKey) + val liteDeviceInfoJson = JsonUtil.createDeviceInfoJson( - deviceId, - deviceName, - localIp, - port, - version, - adbPorts, - WebSocketUtil.currentIpAddress + id = deviceId, + name = deviceName, + ipAddress = localIp, + port = port, + version = version, + wallpaperBase64 = null, + adbPorts = adbPorts, + bleAuthToken = bleAuthToken, + targetIpAddress = WebSocketUtil.currentIpAddress ) if (WebSocketUtil.sendMessage(liteDeviceInfoJson)) { - Log.d(TAG, "Lite device info sent to trigger macInfo") + Log.d(TAG, "Lite device info sent to trigger macInfo (BLE token included)") } else { Log.e(TAG, "Failed to send lite device info") } @@ -220,14 +237,15 @@ object SyncManager { } val currentAdbPorts = discoveredServices.map { it.port.toString() } val fullDeviceInfoJson = JsonUtil.createDeviceInfoJson( - deviceId, - deviceName, - localIp, - port, - version, - wallpaperBase64, - currentAdbPorts, - WebSocketUtil.currentIpAddress + id = deviceId, + name = deviceName, + ipAddress = localIp, + port = port, + version = version, + wallpaperBase64 = wallpaperBase64, + adbPorts = currentAdbPorts, + bleAuthToken = bleAuthToken, + targetIpAddress = WebSocketUtil.currentIpAddress ) if (WebSocketUtil.sendMessage(fullDeviceInfoJson)) { Log.d( @@ -606,4 +624,4 @@ object SyncManager { lastBatteryInfo = null lastVolume = -1 } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/sameerasw/airsync/utils/UDPDiscoveryManager.kt b/app/src/main/java/com/sameerasw/airsync/utils/UDPDiscoveryManager.kt index 73a240b4..d520447f 100644 --- a/app/src/main/java/com/sameerasw/airsync/utils/UDPDiscoveryManager.kt +++ b/app/src/main/java/com/sameerasw/airsync/utils/UDPDiscoveryManager.kt @@ -93,6 +93,10 @@ object UDPDiscoveryManager { } fun start(context: Context, discoveryEnabled: Boolean = true) { + if (!PermissionUtil.isLocalNetworkPermissionGranted(context)) { + Log.d(TAG, "Skipping UDP Discovery Manager start: local network permission not granted") + return + } isDiscoveryEnabled = discoveryEnabled if (isRunning) { updateBroadcastingState(context) @@ -119,6 +123,15 @@ object UDPDiscoveryManager { } fun burstBroadcast(context: Context, durationMs: Long = 30000) { + if (!PermissionUtil.isLocalNetworkPermissionGranted(context)) { + Log.d(TAG, "Skipping burst broadcast: local network permission not granted") + return + } + if (!isDiscoveryEnabled) { + Log.d(TAG, "Discovery disabled, skipping burst broadcast") + return + } + Log.d(TAG, "Starting burst broadcast for ${durationMs}ms") burstJob?.cancel() burstJob = CoroutineScope(Dispatchers.IO).launch { @@ -134,6 +147,11 @@ object UDPDiscoveryManager { private fun updateBroadcastingState(context: Context) { broadcastJob?.cancel() + if (!PermissionUtil.isLocalNetworkPermissionGranted(context)) { + Log.d(TAG, "Skipping broadcasting state update: local network permission not granted") + return + } + if (!isDiscoveryEnabled) { Log.d(TAG, "Discovery broadcasting disabled completely") _discoveredDevices.value = emptyList() @@ -186,35 +204,47 @@ object UDPDiscoveryManager { } } - private fun startListening(context: Context) { - val appContext = context.applicationContext - listeningJob = CoroutineScope(Dispatchers.IO).launch { + fun refreshSocket() { + CoroutineScope(Dispatchers.IO).launch { try { - // Ensure socket is closed before creating new one + Log.d(TAG, "Refreshing UDP discovery socket due to network change") socket?.close() - socket = DatagramSocket(BROADCAST_PORT).apply { - broadcast = true - reuseAddress = true - soTimeout = 0 - } - - val buffer = ByteArray(4096) - while (isRunning) { - try { - val packet = DatagramPacket(buffer, buffer.size) - socket?.receive(packet) + socket = null + } catch (e: Exception) { + Log.e(TAG, "Error refreshing socket: ${e.message}") + } + } + } - val jsonString = String(packet.data, 0, packet.length) - handleIncomingTraffic(appContext, jsonString, packet.address.hostAddress) - } catch (e: Exception) { - if (isRunning) { - Log.e(TAG, "Error receiving packet: ${e.message}") - delay(1000) + private fun startListening(context: Context) { + val appContext = context.applicationContext + listeningJob = CoroutineScope(Dispatchers.IO).launch { + val buffer = ByteArray(4096) + while (isRunning) { + try { + if (socket == null || socket!!.isClosed) { + Log.d(TAG, "Creating new DatagramSocket on port $BROADCAST_PORT") + socket = DatagramSocket(BROADCAST_PORT).apply { + broadcast = true + reuseAddress = true + soTimeout = 0 } } + val packet = DatagramPacket(buffer, buffer.size) + socket?.receive(packet) + + val jsonString = String(packet.data, 0, packet.length) + handleIncomingTraffic(appContext, jsonString, packet.address.hostAddress) + } catch (e: Exception) { + if (isRunning) { + Log.e(TAG, "Error receiving packet: ${e.message}, recreating socket...") + try { + socket?.close() + } catch (_: Exception) {} + socket = null + delay(2000) + } } - } catch (e: Exception) { - Log.e(TAG, "Socket creation failed: ${e.message}") } } } @@ -231,11 +261,10 @@ object UDPDiscoveryManager { handlePresenceMessage(context, json, sourceIp) // Optimization: If we receive a presence packet in PASSIVE mode, - // we might want to respond once so the Mac knows we are here, - // essentially performing a "lazy handshake" + // we respond so the Mac knows we are here (lazy handshake) if (currentMode == DiscoveryMode.PASSIVE && isDiscoveryEnabled) { CoroutineScope(Dispatchers.IO).launch { - // broadcastPresence(context) // Optional: avoid if we want to be truly silent + broadcastPresence(context) } } } @@ -346,6 +375,8 @@ object UDPDiscoveryManager { } private fun broadcastPresence(context: Context) { + if (!isDiscoveryEnabled) return + val allIps = getAllIpAddresses() if (allIps.isEmpty()) { return @@ -427,6 +458,8 @@ object UDPDiscoveryManager { } private fun broadcastGoodbye(context: Context) { + if (!isDiscoveryEnabled) return + val allIps = getAllIpAddresses() if (allIps.isEmpty()) return @@ -447,7 +480,7 @@ object UDPDiscoveryManager { val packet = DatagramPacket( data, data.size, - InetAddress.getByName("255.55.255.255"), + InetAddress.getByName("255.255.255.255"), BROADCAST_PORT ) DatagramSocket(0, InetAddress.getByName(bindIp)).use { sender -> diff --git a/app/src/main/java/com/sameerasw/airsync/utils/WebDavServer.kt b/app/src/main/java/com/sameerasw/airsync/utils/WebDavServer.kt new file mode 100644 index 00000000..22c94ed3 --- /dev/null +++ b/app/src/main/java/com/sameerasw/airsync/utils/WebDavServer.kt @@ -0,0 +1,222 @@ +package com.sameerasw.airsync.utils + +import android.content.Context +import android.os.Environment +import android.util.Log +import io.ktor.http.* +import io.ktor.server.application.* +import io.ktor.server.cio.* +import io.ktor.server.engine.* +import io.ktor.server.plugins.statuspages.* +import io.ktor.server.request.path +import io.ktor.server.response.* +import io.ktor.server.routing.* +import java.io.File +import java.net.ServerSocket +import java.net.URLDecoder +import java.text.SimpleDateFormat +import java.util.* + +class WebDavServer(private val context: Context) { + private var engine: ApplicationEngine? = null + private val port = 9081 + private val TAG = "WebDavServer" + + private val storageRoot = Environment.getExternalStorageDirectory() + + fun start() { + if (engine != null) { + Log.d(TAG, "WebDAV server already initialized") + return + } + + if (!isPortAvailable(port)) { + Log.e(TAG, "WebDAV server cannot start: Port $port is already in use") + return + } + + try { + engine = embeddedServer(CIO, port = port, host = "0.0.0.0") { + install(StatusPages) { + exception { call, cause -> + Log.e(TAG, "Unhandled exception in route", cause) + call.respond(HttpStatusCode.InternalServerError, "Internal Server Error") + } + } + + routing { + // Catch-all: matches any path including root + route("{...}") { + method(HttpMethod.parse("PROPFIND")) { + handle { handlePropfind(call) } + } + get { handleGet(call) } + head { handleHead(call) } + method(HttpMethod.Options) { + handle { + call.response.header("Allow", "GET, HEAD, OPTIONS, PROPFIND") + call.response.header("DAV", "1, 2") + call.respond(HttpStatusCode.OK) + } + } + } + } + } + engine?.start(wait = false) + Log.i(TAG, "WebDAV server started on port $port") + } catch (e: Exception) { + Log.e(TAG, "Failed to start WebDAV server on port $port", e) + engine = null + } + } + + private fun isPortAvailable(port: Int): Boolean { + return try { + ServerSocket(port).use { true } + } catch (e: Exception) { + false + } + } + + fun stop() { + try { + engine?.stop(500, 1000) + } catch (e: Exception) { + Log.e(TAG, "Error stopping WebDAV server", e) + } finally { + engine = null + Log.i(TAG, "WebDAV server stopped") + } + } + + /** + * Extracts the filesystem-relative path from the request URI. + * URL-decodes the path and strips the leading slash so it can be + * joined with storageRoot via File(storageRoot, relativePath). + * Trailing slashes are removed before file resolution. + */ + private fun resolveRequestPath(call: ApplicationCall): String { + val raw = call.request.path() // e.g. "/", "/DCIM/", "/DCIM/Camera/IMG_001.jpg" + val decoded = URLDecoder.decode(raw, "UTF-8") + return decoded.trimStart('/').trimEnd('/') + } + + private suspend fun handlePropfind(call: ApplicationCall) { + val relativePath = resolveRequestPath(call) + val file = File(storageRoot, relativePath) + + Log.d(TAG, "PROPFIND: raw=${call.request.path()} -> resolved=${file.absolutePath}") + + if (!file.exists()) { + Log.w(TAG, "PROPFIND 404: ${file.absolutePath}") + call.respond(HttpStatusCode.NotFound) + return + } + + val depth = call.request.headers["Depth"] ?: "1" + val xml = buildPropfindXml(file, relativePath, depth) + + call.respondText(xml, ContentType.Text.Xml.withParameter("charset", "utf-8"), HttpStatusCode.MultiStatus) + } + + private fun buildPropfindXml(file: File, relativePath: String, depth: String): String { + val sb = StringBuilder() + sb.append("\n") + sb.append("\n") + + appendFileEntry(sb, file, relativePath) + + if (file.isDirectory && depth != "0") { + file.listFiles() + ?.filter { !it.name.startsWith(".") } + ?.sortedWith(compareBy({ !it.isDirectory }, { it.name.lowercase() })) + ?.forEach { child -> + val childRelPath = if (relativePath.isEmpty()) child.name else "$relativePath/${child.name}" + appendFileEntry(sb, child, childRelPath) + } + } + + sb.append("") + return sb.toString() + } + + private fun appendFileEntry(sb: StringBuilder, file: File, relativePath: String) { + val displayName = if (relativePath.isEmpty()) "Android" else file.name + + // Build the href: each path segment is percent-encoded individually, + // but the "/" separators are preserved. Root is always "/". + val href = if (relativePath.isEmpty()) { + "/" + } else { + val encodedSegments = relativePath.split("/").joinToString("/") { segment -> + segment.encodeURLPathPart() + } + // Directories must have trailing slash for WebDAV clients to recognise them + if (file.isDirectory) "/$encodedSegments/" else "/$encodedSegments" + } + + val rfc1123Format = SimpleDateFormat("EEE, dd MMM yyyy HH:mm:ss z", Locale.US).apply { + timeZone = TimeZone.getTimeZone("GMT") + } + val lastModified = rfc1123Format.format(Date(file.lastModified())) + + sb.append(" \n") + sb.append(" $href\n") + sb.append(" \n") + sb.append(" \n") + sb.append(" $displayName\n") + if (file.isDirectory) { + sb.append(" \n") + } else { + sb.append(" \n") + sb.append(" ${file.length()}\n") + val contentType = java.net.URLConnection.guessContentTypeFromName(file.name) ?: "application/octet-stream" + sb.append(" $contentType\n") + } + sb.append(" $lastModified\n") + sb.append(" \n") + sb.append(" HTTP/1.1 200 OK\n") + sb.append(" \n") + sb.append(" \n") + } + + private suspend fun handleGet(call: ApplicationCall) { + val relativePath = resolveRequestPath(call) + val file = File(storageRoot, relativePath) + + Log.d(TAG, "GET: raw=${call.request.path()} -> resolved=${file.absolutePath}") + + if (!file.exists()) { + call.respond(HttpStatusCode.NotFound) + return + } + + if (file.isDirectory) { + call.respond(HttpStatusCode.MethodNotAllowed, "Cannot GET a directory") + return + } + + call.respondFile(file) + } + + private suspend fun handleHead(call: ApplicationCall) { + val relativePath = resolveRequestPath(call) + val file = File(storageRoot, relativePath) + + if (!file.exists()) { + call.respond(HttpStatusCode.NotFound) + return + } + + if (!file.isDirectory) { + call.response.header(HttpHeaders.ContentLength, file.length().toString()) + val contentType = java.net.URLConnection.guessContentTypeFromName(file.name) ?: "application/octet-stream" + call.response.header(HttpHeaders.ContentType, contentType) + } + val rfc1123Format = SimpleDateFormat("EEE, dd MMM yyyy HH:mm:ss z", Locale.US).apply { + timeZone = TimeZone.getTimeZone("GMT") + } + call.response.header(HttpHeaders.LastModified, rfc1123Format.format(Date(file.lastModified()))) + call.respond(HttpStatusCode.OK) + } +} diff --git a/app/src/main/java/com/sameerasw/airsync/utils/WebSocketMessageHandler.kt b/app/src/main/java/com/sameerasw/airsync/utils/WebSocketMessageHandler.kt index 441c4820..1c0cd75f 100644 --- a/app/src/main/java/com/sameerasw/airsync/utils/WebSocketMessageHandler.kt +++ b/app/src/main/java/com/sameerasw/airsync/utils/WebSocketMessageHandler.kt @@ -87,6 +87,7 @@ object WebSocketMessageHandler { "refreshAdbPorts" -> handleRefreshAdbPorts(context) "browseLs" -> handleBrowseLs(context, data) "startQuickShare" -> handleStartQuickShare(context) + "callControl" -> handleCallControl(context, data) else -> { Log.w(TAG, "Unknown message type: $type") } @@ -212,7 +213,7 @@ object WebSocketMessageHandler { } /** - * Handles media control commands (play/pause, next, previous, like). + * Handles media control commands (play/pause, seek, next, previous, like). * Sends a response back to Mac and updates local media state after a short delay. */ private fun handleMediaControl(context: Context, data: JSONObject?) { @@ -243,6 +244,13 @@ object WebSocketMessageHandler { message = if (success) "Playback paused" else "Failed to pause playback" } + "seekTo" -> { + val positionMs = data.optLong("positionMs", -1L) + success = positionMs >= 0L && MediaControlUtil.seekTo(context, positionMs) + message = + if (success) "Seeked to ${positionMs}ms" else "Failed to seek playback" + } + "next" -> { // Suppress automatic media updates before executing skip command SyncManager.suppressMediaUpdatesForSkip() @@ -289,14 +297,15 @@ object WebSocketMessageHandler { // Send updated media state after successful control if (success) { - // For track skip actions (next/previous), add a delay to allow media player to update - CoroutineScope(Dispatchers.IO).launch { - val delayMs = when (action) { - "next", "previous" -> 1200L - else -> 400L // smaller delay for like/others - } - delay(delayMs) - SyncManager.onMediaStateChanged(context) + // For track skip actions (next/previous), add a delay to allow media player to update + CoroutineScope(Dispatchers.IO).launch { + val delayMs = when (action) { + "seekTo" -> 650L + "next", "previous" -> 1200L + else -> 400L // smaller delay for like/others + } + delay(delayMs) + SyncManager.onMediaStateChanged(context) } } } catch (e: Exception) { @@ -305,6 +314,28 @@ object WebSocketMessageHandler { } } + /** + * Handles call control actions (accept, end, decline) from the Mac. + */ + private fun handleCallControl(context: Context, data: JSONObject?) { + try { + if (data == null) { + Log.e(TAG, "Call control data is null") + return + } + + val action = data.optString("action") + Log.d(TAG, "Handling call control action: $action") + when (action) { + "accept" -> CallControlUtil.acceptCall(context) + "end", "decline" -> CallControlUtil.endCall(context) + else -> Log.w(TAG, "Unknown call control action: $action") + } + } catch (e: Exception) { + Log.e(TAG, "Error handling call control command: ${e.message}") + } + } + /** * Attempts to dismiss a notification on the Android device by ID. */ @@ -450,6 +481,10 @@ object WebSocketMessageHandler { if (music?.has("albumArt") == true) music.optString("albumArt", "") else null val likeStatus = music?.optString("likeStatus", "none") ?: "none" + val elapsedTime = ((music?.optDouble("elapsedTime", 0.0) ?: 0.0) * 1000).toLong() + val duration = ((music?.optDouble("duration", 0.0) ?: 0.0) * 1000).toLong() + val timestamp = music?.optString("timestamp") + val playbackRate = music?.optDouble("playbackRate", 1.0) ?: 1.0 val isPaired = data.optBoolean("isPaired", true) @@ -464,6 +499,7 @@ object WebSocketMessageHandler { // Update the Mac device status manager with all media info MacDeviceStatusManager.updateStatus( context = context, + name = data.optString("name", MacDeviceStatusManager.macDeviceStatus.value?.name ?: "Unknown"), batteryLevel = batteryLevel, isCharging = isCharging, isPaired = isPaired, @@ -473,7 +509,11 @@ object WebSocketMessageHandler { volume = volume, isMuted = isMuted, albumArt = albumArt, - likeStatus = likeStatus + likeStatus = likeStatus, + elapsedTime = elapsedTime, + duration = duration, + timestamp = timestamp, + playbackRate = playbackRate ) // Persist a lightweight snapshot for widget consumption and throttle widget refresh @@ -906,4 +946,3 @@ object WebSocketMessageHandler { } } } - diff --git a/app/src/main/java/com/sameerasw/airsync/utils/WebSocketUtil.kt b/app/src/main/java/com/sameerasw/airsync/utils/WebSocketUtil.kt index ba6e47ae..c2b8a861 100644 --- a/app/src/main/java/com/sameerasw/airsync/utils/WebSocketUtil.kt +++ b/app/src/main/java/com/sameerasw/airsync/utils/WebSocketUtil.kt @@ -2,18 +2,24 @@ package com.sameerasw.airsync.utils import android.content.Context import android.util.Log +import com.sameerasw.airsync.data.ble.BleConstants +import com.sameerasw.airsync.data.ble.BleTransportBridge +import com.sameerasw.airsync.domain.model.AudioInfo import com.sameerasw.airsync.widget.AirSyncWidgetProvider import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.delay import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.launch import okhttp3.OkHttpClient import okhttp3.Request import okhttp3.Response import okhttp3.WebSocket import okhttp3.WebSocketListener +import org.json.JSONObject import java.util.concurrent.TimeUnit import java.util.concurrent.atomic.AtomicBoolean @@ -32,6 +38,11 @@ object WebSocketUtil { private val isConnected = AtomicBoolean(false) private val isConnecting = AtomicBoolean(false) + private fun updateConnectedStatus(status: Boolean) { + isConnected.set(status) + _connectionStateFlow.value = status + } + // Transport state: true after OkHttp onOpen, false after closing/failure/disconnect private val isSocketOpen = AtomicBoolean(false) private val handshakeCompleted = AtomicBoolean(false) @@ -57,6 +68,9 @@ object WebSocketUtil { // Global connection status listeners for UI updates private val connectionStatusListeners = mutableSetOf<(Boolean) -> Unit>() + private val _connectionStateFlow = MutableStateFlow(false) + val connectionState = _connectionStateFlow.asStateFlow() + private fun createClient(): OkHttpClient { return OkHttpClient.Builder() .connectTimeout(15, TimeUnit.SECONDS) @@ -229,7 +243,7 @@ object WebSocketUtil { WebSocketUtil.webSocket = webSocket currentIpAddress = ip // Store the successful IP isSocketOpen.set(true) - isConnected.set(false) + updateConnectedStatus(false) isConnecting.set(true) try { @@ -243,7 +257,7 @@ object WebSocketUtil { delay(HANDSHAKE_TIMEOUT_MS) if (!handshakeCompleted.get()) { Log.w(TAG, "Handshake timed out") - isConnected.set(false) + updateConnectedStatus(false) isConnecting.set(false) try { webSocket.close(4001, "Handshake timeout") @@ -293,7 +307,7 @@ object WebSocketUtil { AirSyncWidgetProvider.updateAllWidgets(context) } catch (_: Exception) { } - isConnected.set(true) + updateConnectedStatus(true) isConnecting.set(false) handshakeTimeoutJob?.cancel() try { @@ -364,7 +378,7 @@ object WebSocketUtil { } } } - isConnected.set(false) + updateConnectedStatus(false) isSocketOpen.set(false) isConnecting.set(false) handshakeCompleted.set(false) @@ -419,7 +433,7 @@ object WebSocketUtil { } } } - isConnected.set(false) + updateConnectedStatus(false) isConnecting.set(false) isSocketOpen.set(false) handshakeCompleted.set(false) @@ -511,26 +525,120 @@ object WebSocketUtil { */ fun sendMessage(message: String): Boolean { // Allow sending as soon as the socket is open (even before handshake completes) - return if (isSocketOpen.get() && webSocket != null) { - Log.d(TAG, "Sending message: $message") + if (isSocketOpen.get() && webSocket != null) { + Log.d(TAG, "Sending message via WebSocket: $message") val messageToSend = currentSymmetricKey?.let { key -> CryptoUtil.encryptMessage(message, key) } ?: message - webSocket!!.send(messageToSend) + return webSocket!!.send(messageToSend) } else { - Log.w(TAG, "WebSocket not connected, cannot send message") - false + // Fallback to BLE if authenticated + val ble = com.sameerasw.airsync.AirSyncApp.getBleConnectionManager() + if (ble != null && ble.isAuthenticated) { + Log.d(TAG, "WebSocket not connected, falling back to BLE: $message") + return sendOverBLE(message) + } + + Log.w(TAG, "Neither WebSocket nor BLE connected, cannot send message") + return false } } + private fun sendOverBLE(message: String): Boolean { + val ble = com.sameerasw.airsync.AirSyncApp.getBleConnectionManager() ?: return false + try { + val json = JSONObject(message) + val type = json.optString("type") + val data = json.optJSONObject("data") ?: JSONObject() + + when (type) { + "notificationAction" -> { + val pkg = data.optString("package") + val actionId = data.optString("actionId") + val payload = "$pkg|$actionId" + ble.sendChunkedNotification(BleConstants.CHAR_NOTIFICATION_ACTION, payload) + return true + } + "mediaControl" -> { + val action = data.optString("action") + // Protocol: type|action + val payload = "media|$action" + ble.sendChunkedNotification(BleConstants.CHAR_MAC_CONTROL, payload) + return true + } + "volumeControl" -> { + val action = data.optString("action") + // Protocol: type|action + val payload = "volume|$action" + ble.sendChunkedNotification(BleConstants.CHAR_MAC_CONTROL, payload) + return true + } + "clipboard", "clipboardUpdate" -> { + val content = data.optString("text", data.optString("content")) + ble.sendChunkedNotification(BleConstants.CHAR_CLIPBOARD_DATA_NOTIFY, content) + return true + } + "dismissNotification" -> { + val id = data.optString("id") + ble.sendChunkedNotification(BleConstants.CHAR_NOTIFICATION_DISMISS_NOTIFY, id) + return true + } + "remoteControl" -> { + val action = data.optString("action") + // Filter out high-frequency cursor controls over BLE + if (action == "mouse_move" || action == "mouse_click" || action == "mouse_scroll") { + return false + } + // Include value if present (e.g. vol_set needs level) + val value = if (data.has("value")) data.opt("value")?.toString() else null + val payload = if (value != null) "remote|$action|$value" else "remote|$action" + ble.sendChunkedNotification(BleConstants.CHAR_MAC_CONTROL, payload) + return true + } + "notification" -> { + val pkg = data.optString("package") + val appName = data.optString("app") + val title = data.optString("title") + val body = data.optString("body") + BleTransportBridge.sendNotification(pkg, appName, title, body) + return true + } + "status" -> { + val battery = data.optJSONObject("battery") + if (battery != null) { + val level = battery.optInt("level") + ble.sendNotification(BleConstants.CHAR_BATTERY_LEVEL, byteArrayOf(level.toByte())) + } + val music = data.optJSONObject("music") + if (music != null) { + val audio = AudioInfo( + isPlaying = music.optBoolean("isPlaying"), + title = music.optString("title"), + artist = music.optString("artist"), + volume = music.optInt("volume"), + isMuted = music.optBoolean("isMuted"), + likeStatus = music.optString("likeStatus"), + albumArtLite = music.optString("albumArtLite") + ) + BleTransportBridge.sendMediaState(audio) + } + return true + } + } + } catch (e: Exception) { + Log.e(TAG, "Error sending over BLE fallback: ${e.message}") + } + return false + } + /** * Disconnects the WebSocket and cleans up resources. * Stops related services (AirSyncService, periodic sync) and updates UI state. */ fun disconnect(context: Context? = null) { Log.d(TAG, "Disconnecting WebSocket") - isConnected.set(false) + updateConnectedStatus(false) isConnecting.set(false) isSocketOpen.set(false) handshakeCompleted.set(false) @@ -547,6 +655,22 @@ object WebSocketUtil { } catch (_: Exception) { } } + + // Send manual disconnect signal over BLE before disconnecting BLE client + try { + val ble = com.sameerasw.airsync.AirSyncApp.getBleConnectionManager() + if (ble != null && ble.isAuthenticated) { + Log.d(TAG, "Sending manual disconnect signal over BLE before disconnecting") + ble.sendChunkedNotification(BleConstants.CHAR_MAC_CONTROL, "remote|manual_disconnect") + + CoroutineScope(Dispatchers.IO).launch { + delay(300) + ble.disconnectAllConnectedDevices() + } + } + } catch (e: Exception) { + Log.e(TAG, "Error sending manual disconnect signal over BLE: ${e.message}") + } } webSocket?.close(1000, "Manual disconnection") @@ -612,7 +736,7 @@ object WebSocketUtil { } fun isConnected(): Boolean { - return isConnected.get() + return isConnected.get() || com.sameerasw.airsync.data.ble.BleGattServer.isAnyAuthenticated() } fun isConnecting(): Boolean { diff --git a/app/src/main/res/drawable/quick_share.xml b/app/src/main/res/drawable/quick_share.xml new file mode 100644 index 00000000..7eef4496 --- /dev/null +++ b/app/src/main/res/drawable/quick_share.xml @@ -0,0 +1,27 @@ + + + + + diff --git a/app/src/main/res/drawable/rounded_android_24.xml b/app/src/main/res/drawable/rounded_android_24.xml new file mode 100644 index 00000000..c412317c --- /dev/null +++ b/app/src/main/res/drawable/rounded_android_24.xml @@ -0,0 +1,20 @@ + + + + + + diff --git a/app/src/main/res/drawable/rounded_bluetooth_24.xml b/app/src/main/res/drawable/rounded_bluetooth_24.xml new file mode 100644 index 00000000..9600872a --- /dev/null +++ b/app/src/main/res/drawable/rounded_bluetooth_24.xml @@ -0,0 +1,20 @@ + + + + + + diff --git a/app/src/main/res/drawable/rounded_bluetooth_searching_24.xml b/app/src/main/res/drawable/rounded_bluetooth_searching_24.xml new file mode 100644 index 00000000..341352de --- /dev/null +++ b/app/src/main/res/drawable/rounded_bluetooth_searching_24.xml @@ -0,0 +1,20 @@ + + + + + + diff --git a/app/src/main/res/drawable/rounded_bug_report_24.xml b/app/src/main/res/drawable/rounded_bug_report_24.xml new file mode 100644 index 00000000..2899c219 --- /dev/null +++ b/app/src/main/res/drawable/rounded_bug_report_24.xml @@ -0,0 +1,20 @@ + + + + + + diff --git a/app/src/main/res/drawable/rounded_dark_mode_24.xml b/app/src/main/res/drawable/rounded_dark_mode_24.xml new file mode 100644 index 00000000..0788c72e --- /dev/null +++ b/app/src/main/res/drawable/rounded_dark_mode_24.xml @@ -0,0 +1,20 @@ + + + + + + diff --git a/app/src/main/res/drawable/rounded_extension_24.xml b/app/src/main/res/drawable/rounded_extension_24.xml new file mode 100644 index 00000000..73d85e26 --- /dev/null +++ b/app/src/main/res/drawable/rounded_extension_24.xml @@ -0,0 +1,20 @@ + + + + + + diff --git a/app/src/main/res/drawable/rounded_history_24.xml b/app/src/main/res/drawable/rounded_history_24.xml new file mode 100644 index 00000000..a273bff9 --- /dev/null +++ b/app/src/main/res/drawable/rounded_history_24.xml @@ -0,0 +1,20 @@ + + + + + + diff --git a/app/src/main/res/drawable/rounded_music_cast_24.xml b/app/src/main/res/drawable/rounded_music_cast_24.xml new file mode 100644 index 00000000..4fd8dcc2 --- /dev/null +++ b/app/src/main/res/drawable/rounded_music_cast_24.xml @@ -0,0 +1,20 @@ + + + + + + diff --git a/app/src/main/res/drawable/rounded_shield_toggle_24.xml b/app/src/main/res/drawable/rounded_shield_toggle_24.xml new file mode 100644 index 00000000..74e500a3 --- /dev/null +++ b/app/src/main/res/drawable/rounded_shield_toggle_24.xml @@ -0,0 +1,20 @@ + + + + + + diff --git a/app/src/main/res/drawable/rounded_smart_display_24.xml b/app/src/main/res/drawable/rounded_smart_display_24.xml new file mode 100644 index 00000000..e653df58 --- /dev/null +++ b/app/src/main/res/drawable/rounded_smart_display_24.xml @@ -0,0 +1,20 @@ + + + + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index d752d8c8..cbbe1204 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -81,4 +81,30 @@ Auto App icon credits: @Syntrop2k2 on Telegram https://t.me/Syntrop2k2 + + + Reconnect + Configure auto reconnect + Auto re-connect + Attempted when disconnected unexpectedly + Switch to Nearby + Use Bluetooth LE if connection lost + File Access + Mount storage in macOS Finder + + + Local Network Access + Discover nearby Mac devices on Wi-Fi + AirSync needs access to your local network to discover and connect to your Mac over Wi-Fi. + Local network discovery allows the app to find and establish a secure, fast connection with your Mac on the same Wi-Fi network for syncing notifications, clipboard, and controlling media controls. Without this, Wi-Fi synchronization cannot function on Android 17+. + Grant Local Network Access + + + Answer Calls + Allows accepting and ending calls from your Mac + AirSync needs permission to manage phone calls so you can accept or decline them directly from your Mac companion window. + When you receive an incoming call, this permission enables the companion app to answer or decline/end the call programmatically in response to your actions on the Mac. Without this permission, the app will fall back to emulating headphone media click signals which may only toggle the call state rather than allowing explicit decline actions. + Grant Answer Calls Access + Select apps + To be notified \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 894c19be..79dd1811 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,5 +1,5 @@ [versions] -agp = "9.0.1" +agp = "9.2.1" kotlin = "2.3.0" coreKtx = "1.10.1" junit = "4.13.2" @@ -20,6 +20,7 @@ sentry = "8.0.0" protobuf = "4.28.2" wire = "6.0.0-alpha03" bouncycastle = "1.78.1" +ktor = "2.3.12" [libraries] androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } @@ -55,6 +56,14 @@ sentry-android = { group = "io.sentry", name = "sentry-android", version.ref = " wire-runtime = { group = "com.squareup.wire", name = "wire-runtime", version.ref = "wire" } bouncycastle = { group = "org.bouncycastle", name = "bcprov-jdk18on", version.ref = "bouncycastle" } +# Ktor Server for WebDAV +ktor-server-core = { group = "io.ktor", name = "ktor-server-core", version.ref = "ktor" } +ktor-server-cio = { group = "io.ktor", name = "ktor-server-cio", version.ref = "ktor" } +ktor-server-host-common = { group = "io.ktor", name = "ktor-server-host-common", version.ref = "ktor" } +ktor-server-status-pages = { group = "io.ktor", name = "ktor-server-status-pages", version.ref = "ktor" } +ktor-server-content-negotiation = { group = "io.ktor", name = "ktor-server-content-negotiation", version.ref = "ktor" } +ktor-serialization-gson = { group = "io.ktor", name = "ktor-serialization-gson", version.ref = "ktor" } + [plugins] android-application = { id = "com.android.application", version.ref = "agp" } kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index d858fdf8..66fe04e3 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ #Mon Jul 28 23:54:01 IST 2025 distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-9.1.0-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-9.4.1-bin.zip zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists diff --git a/settings.gradle.kts b/settings.gradle.kts index c4f35140..9ff59c83 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -11,6 +11,9 @@ pluginManagement { gradlePluginPortal() } } +plugins { + id("org.gradle.toolchains.foojay-resolver-convention") version "1.0.0" +} dependencyResolutionManagement { repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) repositories {