diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index dd1d5d7c..4ac949a5 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -112,13 +112,33 @@ android:noHistory="true" android:taskAffinity="" android:theme="@style/Theme.AirSync.Transparent"> + + + - + + + + + + + + { + Log.d(TAG, "Received manual disconnect from Mac via BLE") + com.sameerasw.airsync.utils.WebSocketUtil.disconnect(context, manual = true) + gattServer?.disconnectAllConnectedDevices() + } + 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( diff --git a/app/src/main/java/com/sameerasw/airsync/presentation/ui/activities/ClipboardActionActivity.kt b/app/src/main/java/com/sameerasw/airsync/presentation/ui/activities/ClipboardActionActivity.kt index 64c8458b..d540e3fa 100644 --- a/app/src/main/java/com/sameerasw/airsync/presentation/ui/activities/ClipboardActionActivity.kt +++ b/app/src/main/java/com/sameerasw/airsync/presentation/ui/activities/ClipboardActionActivity.kt @@ -159,9 +159,8 @@ private fun ClipboardActionScreen( ShortcutUtil.DASH_ACTION_PAUSE -> { val ds = DataStoreManager.getInstance(context) ds.setAppPaused(true) - ds.setUserManuallyDisconnected(true) WebSocketUtil.stopAutoReconnect(context) - WebSocketUtil.disconnect(context) + WebSocketUtil.disconnect(context, manual = true) com.sameerasw.airsync.utils.discovery.DiscoveryOrchestrator.stop(context) com.sameerasw.airsync.service.AirSyncService.stop(context) ShortcutUtil.refreshShortcuts(context, false) @@ -185,9 +184,7 @@ private fun ClipboardActionScreen( } ShortcutUtil.DASH_ACTION_DISCONNECT -> { - val ds = DataStoreManager.getInstance(context) - ds.setUserManuallyDisconnected(true) - WebSocketUtil.disconnect(context) + WebSocketUtil.disconnect(context, manual = true) uiState = ClipboardUiState.Success delay(1200) onFinished() diff --git a/app/src/main/java/com/sameerasw/airsync/presentation/ui/activities/QuickShareSendActivity.kt b/app/src/main/java/com/sameerasw/airsync/presentation/ui/activities/QuickShareSendActivity.kt new file mode 100644 index 00000000..747d397c --- /dev/null +++ b/app/src/main/java/com/sameerasw/airsync/presentation/ui/activities/QuickShareSendActivity.kt @@ -0,0 +1,311 @@ +package com.sameerasw.airsync.presentation.ui.activities + +import android.content.Context +import android.content.Intent +import android.graphics.Color +import android.graphics.drawable.ColorDrawable +import android.net.Uri +import android.os.Build +import android.os.Bundle +import android.os.Parcelable +import android.provider.OpenableColumns +import android.util.Log +import androidx.activity.ComponentActivity +import androidx.activity.SystemBarStyle +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.ExperimentalAnimationApi +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.togetherWith +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.CheckCircle +import androidx.compose.material.icons.rounded.Error +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.Icon +import androidx.compose.material3.LoadingIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel +import com.sameerasw.airsync.R +import com.sameerasw.airsync.presentation.viewmodel.AirSyncViewModel +import com.sameerasw.airsync.service.OutboundQuickShareService +import com.sameerasw.airsync.ui.theme.AirSyncTheme +import com.sameerasw.airsync.utils.ClipboardSyncManager +import com.sameerasw.airsync.utils.ShortcutUtil +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.withContext +import java.io.File + +class QuickShareSendActivity : ComponentActivity() { + + private val _windowFocus = mutableStateOf(false) + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + enableEdgeToEdge( + statusBarStyle = SystemBarStyle.auto(Color.TRANSPARENT, Color.TRANSPARENT), + navigationBarStyle = SystemBarStyle.auto(Color.TRANSPARENT, Color.TRANSPARENT) + ) + window.setBackgroundDrawable(ColorDrawable(Color.TRANSPARENT)) + + val uris = extractUris(intent) + val sharedText = if (uris.isEmpty()) intent?.getStringExtra(Intent.EXTRA_TEXT) else null + + setContent { + val viewModel: AirSyncViewModel = viewModel { AirSyncViewModel.create(this@QuickShareSendActivity) } + val uiState by viewModel.uiState.collectAsState() + + AirSyncTheme(pitchBlackTheme = uiState.isPitchBlackThemeEnabled) { + QuickShareSendScreen( + hasWindowFocus = _windowFocus.value, + uris = uris, + sharedText = sharedText, + pairedDeviceName = uiState.lastConnectedDevice?.name, + onFinished = { finish() } + ) + } + } + } + + override fun onWindowFocusChanged(hasFocus: Boolean) { + super.onWindowFocusChanged(hasFocus) + _windowFocus.value = hasFocus + } + + private fun extractUris(intent: Intent?): List { + if (intent == null) return emptyList() + return when (intent.action) { + Intent.ACTION_SEND -> { + val uri = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + intent.getParcelableExtra(Intent.EXTRA_STREAM, Uri::class.java) + } else { + @Suppress("DEPRECATION") + intent.getParcelableExtra(Intent.EXTRA_STREAM) + } + listOfNotNull(uri) + } + + Intent.ACTION_SEND_MULTIPLE -> { + val list: ArrayList? = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + intent.getParcelableArrayListExtra(Intent.EXTRA_STREAM, Uri::class.java) + } else { + @Suppress("DEPRECATION") + intent.getParcelableArrayListExtra(Intent.EXTRA_STREAM) + } + list?.filterIsInstance() ?: emptyList() + } + + else -> emptyList() + } + } +} + +@OptIn(ExperimentalAnimationApi::class, ExperimentalMaterial3ExpressiveApi::class) +@Composable +private fun QuickShareSendScreen( + hasWindowFocus: Boolean, + uris: List, + sharedText: String?, + pairedDeviceName: String?, + onFinished: () -> Unit +) { + val context = LocalContext.current + var uiState by remember { mutableStateOf(SendUiState.Loading) } + var hasStarted by remember { mutableStateOf(false) } + + LaunchedEffect(hasWindowFocus) { + if (hasWindowFocus && !hasStarted) { + hasStarted = true + delay(100) + + if (uris.isEmpty() && sharedText.isNullOrBlank()) { + uiState = SendUiState.Error("Nothing to send") + delay(1500) + onFinished() + } else if (pairedDeviceName.isNullOrBlank()) { + uiState = SendUiState.Error("No Mac paired") + delay(1500) + onFinished() + } else if (uris.isEmpty()) { + ClipboardSyncManager.syncTextToDesktop(sharedText!!) + reportSummonShortcutUsed(context) + uiState = SendUiState.Success + delay(1200) + onFinished() + } else { + val localUris = withContext(Dispatchers.IO) { copyUrisToCache(context, uris) } + if (localUris.isEmpty()) { + uiState = SendUiState.Error("Couldn't read the file") + delay(1500) + onFinished() + } else { + OutboundQuickShareService.start(context, localUris) + reportSummonShortcutUsed(context) + uiState = SendUiState.Success + delay(1200) + onFinished() + } + } + } + } + + Box( + modifier = Modifier + .fillMaxSize() + .clickable(onClick = onFinished), + contentAlignment = Alignment.BottomCenter + ) { + Surface( + modifier = Modifier + .navigationBarsPadding() + .padding(bottom = 64.dp) + .padding(horizontal = 24.dp), + shape = RoundedCornerShape(percent = 50), + color = MaterialTheme.colorScheme.surfaceContainerHigh, + tonalElevation = 8.dp, + shadowElevation = 12.dp + ) { + Row( + modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = androidx.compose.foundation.layout.Arrangement.spacedBy(12.dp) + ) { + Icon( + painter = painterResource(id = R.drawable.ic_laptop_24), + contentDescription = null, + modifier = Modifier.size(24.dp), + tint = MaterialTheme.colorScheme.primary + ) + + val label = when (uiState) { + is SendUiState.Loading -> "Sending to ${pairedDeviceName ?: stringResource(R.string.your_mac)}..." + is SendUiState.Success -> "Sending to ${pairedDeviceName ?: stringResource(R.string.your_mac)}" + is SendUiState.Error -> (uiState as SendUiState.Error).message + } + Text( + text = label, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurface + ) + + AnimatedContent( + targetState = uiState, + transitionSpec = { fadeIn().togetherWith(fadeOut()) }, + label = "StatusAnimation" + ) { state -> + Box(modifier = Modifier.size(28.dp), contentAlignment = Alignment.Center) { + when (state) { + is SendUiState.Loading -> LoadingIndicator( + modifier = Modifier.size(24.dp), + color = MaterialTheme.colorScheme.primary + ) + + is SendUiState.Success -> Icon( + imageVector = Icons.Rounded.CheckCircle, + contentDescription = "Success", + modifier = Modifier.size(28.dp), + tint = MaterialTheme.colorScheme.primary + ) + + is SendUiState.Error -> Icon( + imageVector = Icons.Rounded.Error, + contentDescription = "Error", + tint = MaterialTheme.colorScheme.error, + modifier = Modifier.size(28.dp) + ) + } + } + } + } + } + } +} + +sealed class SendUiState { + data object Loading : SendUiState() + data object Success : SendUiState() + data class Error(val message: String) : SendUiState() +} + +private fun reportSummonShortcutUsed(context: Context) { + try { + ShortcutUtil.reportShareMacShortcutUsed(context) + } catch (_: Exception) { + + } +} + +private fun copyUrisToCache(context: Context, uris: List): List { + val outDir = File(context.cacheDir, "quickshare_outgoing").apply { mkdirs() } + return uris.mapNotNull { uri -> + try { + var name = "file_${System.currentTimeMillis()}" + context.contentResolver.query(uri, null, null, null, null)?.use { cursor -> + val nameIdx = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME) + if (cursor.moveToFirst() && nameIdx >= 0) { + cursor.getString(nameIdx)?.let { name = it } + } + } + + var dest = File(outDir, name) + var counter = 1 + while (dest.exists()) { + val base = name.substringBeforeLast(".") + val ext = name.substringAfterLast(".", "") + dest = File(outDir, if (ext.isEmpty()) "$base ($counter)" else "$base ($counter).$ext") + counter++ + } + + context.contentResolver.openInputStream(uri)?.use { input -> + dest.outputStream().use { output -> input.copyTo(output) } + } ?: return@mapNotNull null + + Uri.fromFile(dest) + } catch (e: Exception) { + Log.e("QuickShareSendActivity", "Failed to cache $uri", e) + null + } + } +} + +@Preview(name = "Loading", showBackground = true) +@Composable +private fun QuickShareSendScreenPreviewLoading() { + AirSyncTheme { + QuickShareSendScreen( + hasWindowFocus = true, + uris = emptyList(), + sharedText = null, + pairedDeviceName = "Sameera's Mac", + onFinished = {} + ) + } +} 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 fd242a95..96d1e55e 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 @@ -190,8 +190,8 @@ private fun getPermissionInfo(context: Context, permissionType: PermissionType): PermissionType.WALLPAPER_ACCESS -> PermissionInfo( title = "Wallpaper & File Access", icon = R.drawable.rounded_folder_managed_24, - description = "This optional permission allows AirSync to sync your phone's wallpaper to your mac.", - whyNeeded = "To read your current wallpaper which is not accessible with regular privileges, AirSync needs external storage permissions. \nBut the app will only use the permission for the given explained use cases and will not alter or read any other files on the storage.", + description = "This optional permission allows AirSync to sync your phone's wallpaper to your Mac, and lets your Mac browse your phone's storage as a Finder volume when the File Access feature is turned on.", + whyNeeded = "To read your current wallpaper, and to let your paired Mac mount your phone's storage over Wi-Fi (the File Access feature in the Mac app's settings), AirSync needs broader storage permissions than regular apps get. \nThis permission is only used for these two features — wallpaper sync always, and file browsing only while File Access is explicitly enabled on your Mac and only over your local network.", buttonText = "Grant Storage Access" ) 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 1f690e6e..1a18968b 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 @@ -542,8 +542,7 @@ fun AirSyncMainScreen( fun disconnect() { scope.launch { - viewModel.setUserManuallyDisconnectedAwait(true) - WebSocketUtil.disconnect(context) + WebSocketUtil.disconnect(context, manual = true) viewModel.setConnectionStatus(isConnected = false, isConnecting = false) viewModel.clearClipboardHistory() viewModel.setResponse("Disconnected") 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 11eb4c2e..819827e4 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 @@ -181,10 +181,10 @@ fun PermissionsScreen( ) } - "Wallpaper Access" -> { + "Wallpaper & File Access" -> { PermissionButton( permissionName = permission, - description = "Enables wallpaper sync", + description = "Enables wallpaper sync and Mac File Access", onExplainClick = { showDialog = PermissionType.WALLPAPER_ACCESS }, 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 3040c3b7..429c712a 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 @@ -840,18 +840,12 @@ class AirSyncViewModel( } } - // Awaitable variant used when ordering matters (e.g., ensure flag is persisted before disconnect) - suspend fun setUserManuallyDisconnectedAwait(disconnected: Boolean) { - repository.setUserManuallyDisconnected(disconnected) - } - fun setAppPaused(context: Context, paused: Boolean) { viewModelScope.launch { repository.setAppPaused(paused) if (paused) { - repository.setUserManuallyDisconnected(true) WebSocketUtil.stopAutoReconnect(context) - WebSocketUtil.disconnect(context) + WebSocketUtil.disconnect(context, manual = true) com.sameerasw.airsync.utils.discovery.DiscoveryOrchestrator.stop(context) com.sameerasw.airsync.service.AirSyncService.stop(context) _uiState.value = _uiState.value.copy( diff --git a/app/src/main/java/com/sameerasw/airsync/quickshare/OutboundQuickShareConnection.kt b/app/src/main/java/com/sameerasw/airsync/quickshare/OutboundQuickShareConnection.kt new file mode 100644 index 00000000..821c4c1b --- /dev/null +++ b/app/src/main/java/com/sameerasw/airsync/quickshare/OutboundQuickShareConnection.kt @@ -0,0 +1,367 @@ +package com.sameerasw.airsync.quickshare + +import android.content.Context +import android.net.Uri +import android.util.Log +import com.google.android.gms.nearby.sharing.ConnectionResponseFrame as SharingResponse +import com.google.android.gms.nearby.sharing.Frame +import com.google.android.gms.nearby.sharing.FileMetadata +import com.google.android.gms.nearby.sharing.IntroductionFrame +import com.google.android.gms.nearby.sharing.PairedKeyEncryptionFrame +import com.google.android.gms.nearby.sharing.PairedKeyResultFrame +import com.google.android.gms.nearby.sharing.V1Frame as SharingV1 +import com.google.location.nearby.connections.proto.ConnectionRequestFrame +import com.google.location.nearby.connections.proto.OfflineFrame +import com.google.location.nearby.connections.proto.PayloadTransferFrame +import com.google.location.nearby.connections.proto.PayloadTransferFrame.PayloadHeader +import com.google.location.nearby.connections.proto.V1Frame +import com.google.security.cryptauth.lib.securegcm.Ukey2Message +import com.google.security.cryptauth.lib.securegcm.Ukey2ServerInit +import okio.ByteString.Companion.toByteString +import java.io.InputStream +import java.net.Socket +import java.util.concurrent.Executors + +class OutboundQuickShareConnection( + private val context: Context, + private val socket: Socket, + private val ourDeviceName: String, + private val files: List +) : QuickShareConnection(socket.getInputStream(), socket.getOutputStream()) { + + data class PendingFile( + val uri: Uri, + val name: String, + val mimeType: String, + val size: Long, + val payloadId: Long = kotlin.random.Random.nextLong() + ) + + var onConnectionReady: ((pin: String) -> Unit)? = null + var onRejected: (() -> Unit)? = null + var onFileProgress: ((fileName: String, percent: Int, bytesTransferred: Long, totalSize: Long, transferId: String) -> Unit)? = + null + var onFileComplete: ((fileName: String, transferId: String, success: Boolean) -> Unit)? = null + var onFinished: (() -> Unit)? = null + var onError: ((Throwable) -> Unit)? = null + + companion object { + private const val TAG = "OutboundQSConnection" + private const val CHUNK_SIZE = 512 * 1024 + } + + private val executor = Executors.newSingleThreadExecutor() + private var isRunning = true + + fun start() { + executor.execute { + try { + runHandshake() + } catch (e: Exception) { + Log.e(TAG, "Outbound handshake/transfer failed", e) + onError?.invoke(e) + closeConnection() + } + } + } + + private fun runHandshake() { + val request = OfflineFrame( + version = OfflineFrame.Version.V1, + v1 = V1Frame( + type = V1Frame.FrameType.CONNECTION_REQUEST, + connection_request = ConnectionRequestFrame( + endpoint_id = randomEndpointId(), + endpoint_name = ourDeviceName, + endpoint_info = serializeEndpointInfo(ourDeviceName).toByteString() + ) + ) + ) + writeFrame(request.encode()) + Log.d(TAG, "Sent ConnectionRequest") + + // 2. UKEY2 handshake (we are client) + val ukey2 = Ukey2Context() + val clientInit = ukey2.buildClientInit() + val clientInitEnvelope = Ukey2Message( + message_type = Ukey2Message.Type.CLIENT_INIT, + message_data = clientInit.encode().toByteString() + ) + val clientInitEnvelopeBytes = clientInitEnvelope.encode() + writeFrame(clientInitEnvelopeBytes) + Log.d(TAG, "Sent ClientInit") + + val serverInitEnvelopeBytes = readFrame() + val serverInitEnvelope = Ukey2Message.ADAPTER.decode(serverInitEnvelopeBytes) + if (serverInitEnvelope.message_type != Ukey2Message.Type.SERVER_INIT) { + throw IllegalStateException("Expected SERVER_INIT, got ${serverInitEnvelope.message_type}") + } + val serverInit = Ukey2ServerInit.ADAPTER.decode(serverInitEnvelope.message_data!!) + ukey2.handleServerInit(serverInit, clientInitEnvelopeBytes, serverInitEnvelopeBytes) + Log.d(TAG, "Received ServerInit, derived keys. PIN: ${ukey2.authString}") + + writeFrame(ukey2.clientFinishEnvelopeBytes!!) + Log.d(TAG, "Sent ClientFinish") + + setUkey2Context(ukey2) + onConnectionReady?.invoke(ukey2.authString ?: "----") + + val ourResponse = OfflineFrame( + version = OfflineFrame.Version.V1, + v1 = V1Frame( + type = V1Frame.FrameType.CONNECTION_RESPONSE, + connection_response = com.google.location.nearby.connections.proto.ConnectionResponseFrame( + response = com.google.location.nearby.connections.proto.ConnectionResponseFrame.ResponseStatus.ACCEPT, + status = 0 + ) + ) + ) + writeFrame(ourResponse.encode()) + Log.d(TAG, "Sent our ConnectionResponse (ACCEPT)") + + val remoteResponseData = readFrame() + val remoteResponse = OfflineFrame.ADAPTER.decode(remoteResponseData) + if (remoteResponse.v1?.type != V1Frame.FrameType.CONNECTION_RESPONSE) { + throw IllegalStateException("Expected CONNECTION_RESPONSE, got ${remoteResponse.v1?.type}") + } + if (remoteResponse.v1.connection_response?.response != com.google.location.nearby.connections.proto.ConnectionResponseFrame.ResponseStatus.ACCEPT) { + throw IllegalStateException("Receiver rejected the connection") + } + Log.d(TAG, "Receiver accepted the connection") + + val ourPairedKeyEnc = Frame( + version = Frame.Version.V1, + v1 = SharingV1( + type = SharingV1.FrameType.PAIRED_KEY_ENCRYPTION, + paired_key_encryption = PairedKeyEncryptionFrame( + signed_data = randomBytes(72), + secret_id_hash = randomBytes(6) + ) + ) + ) + writeSharingFrame(ourPairedKeyEnc) + Log.d(TAG, "Sent PairedKeyEncryption") + + val remotePairedKeyEnc = readSharingFrame() + Log.d(TAG, "Received PairedKeyEncryption from receiver: ${remotePairedKeyEnc.v1?.type}") + + val ourPairedKeyResult = Frame( + version = Frame.Version.V1, + v1 = SharingV1( + type = SharingV1.FrameType.PAIRED_KEY_RESULT, + paired_key_result = PairedKeyResultFrame(status = PairedKeyResultFrame.Status.UNABLE) + ) + ) + writeSharingFrame(ourPairedKeyResult) + Log.d(TAG, "Sent PairedKeyResult") + + val remotePairedKeyResult = readSharingFrame() + Log.d(TAG, "Received PairedKeyResult from receiver: ${remotePairedKeyResult.v1?.type}") + + val introduction = IntroductionFrame( + file_metadata = files.map { f -> + FileMetadata( + name = f.name, + payload_id = f.payloadId, + size = f.size, + mime_type = f.mimeType + ) + } + ) + writeSharingFrame( + Frame( + version = Frame.Version.V1, + v1 = SharingV1(type = SharingV1.FrameType.INTRODUCTION, introduction = introduction) + ) + ) + Log.d(TAG, "Sent Introduction (${files.size} file(s))") + + val response = waitForResponse() + if (response != SharingResponse.Status.ACCEPT) { + Log.d(TAG, "Receiver declined the transfer") + onRejected?.invoke() + closeConnection() + return + } + Log.d(TAG, "Receiver accepted the transfer, sending files") + + for (file in files) { + sendFile(file) + } + + try { + socket.shutdownOutput() + socket.soTimeout = 5000 + readFrame() + } catch (e: Exception) { + Log.d(TAG, "Post-send wait ended (expected once the receiver finishes/closes): $e") + } + + onFinished?.invoke() + closeConnection() + } + + private fun waitForResponse(): SharingResponse.Status { + while (isRunning) { + val frame = readSharingFrame() + val v1 = frame.v1 ?: continue + when (v1.type) { + SharingV1.FrameType.RESPONSE -> { + return v1.connection_response?.status ?: SharingResponse.Status.UNKNOWN + } + + SharingV1.FrameType.CANCEL -> { + return SharingResponse.Status.REJECT + } + + else -> Log.d(TAG, "Ignoring sharing frame while waiting for response: ${v1.type}") + } + } + return SharingResponse.Status.REJECT + } + + private fun sendFile(file: PendingFile) { + val stream: InputStream = context.contentResolver.openInputStream(file.uri) + ?: throw IllegalStateException("Could not open ${file.name}") + + stream.use { input -> + val buffer = ByteArray(CHUNK_SIZE) + var offset = 0L + var lastUpdate = 0L + + while (true) { + val read = input.read(buffer) + if (read <= 0) break + + val chunkBody = if (read == buffer.size) buffer else buffer.copyOf(read) + writePayloadChunk(file, offset = offset, flags = 0, body = chunkBody) + offset += read + + val now = System.currentTimeMillis() + if (now - lastUpdate > 250) { + lastUpdate = now + val percent = if (file.size > 0) ((offset * 100) / file.size).toInt() else 100 + onFileProgress?.invoke(file.name, percent, offset, file.size, file.payloadId.toString()) + } + } + + writePayloadChunk(file, offset = offset, flags = 1, body = null) + onFileProgress?.invoke(file.name, 100, offset, file.size, file.payloadId.toString()) + } + + Log.d(TAG, "Finished sending ${file.name}") + onFileComplete?.invoke(file.name, file.payloadId.toString(), true) + } + + private fun writePayloadChunk(file: PendingFile, offset: Long, flags: Int, body: ByteArray?) { + writeEncryptedMessage( + OfflineFrame( + version = OfflineFrame.Version.V1, + v1 = V1Frame( + type = V1Frame.FrameType.PAYLOAD_TRANSFER, + payload_transfer = PayloadTransferFrame( + packet_type = PayloadTransferFrame.PacketType.DATA, + payload_header = PayloadHeader( + id = file.payloadId, + type = PayloadHeader.PayloadType.FILE, + total_size = file.size, + file_name = file.name + ), + payload_chunk = PayloadTransferFrame.PayloadChunk( + offset = offset, + flags = flags, + body = body?.toByteString() + ) + ) + ) + ).encode() + ) + } + + private fun readSharingFrame(): Frame { + val d2dPayload = readEncryptedMessage() + val offlineFrame = OfflineFrame.ADAPTER.decode(d2dPayload) + val payloadBody = offlineFrame.v1!!.payload_transfer!!.payload_chunk!!.body!!.toByteArray() + readEncryptedMessage() // discard the last-chunk marker frame + return Frame.ADAPTER.decode(payloadBody) + } + + private fun writeSharingFrame(frame: Frame) { + val frameBytes = frame.encode() + val payloadId = kotlin.random.Random.nextLong() + val dataFrame = OfflineFrame( + version = OfflineFrame.Version.V1, + v1 = V1Frame( + type = V1Frame.FrameType.PAYLOAD_TRANSFER, + payload_transfer = PayloadTransferFrame( + packet_type = PayloadTransferFrame.PacketType.DATA, + payload_header = PayloadHeader( + id = payloadId, + type = PayloadHeader.PayloadType.BYTES, + total_size = frameBytes.size.toLong(), + is_sensitive = false + ), + payload_chunk = PayloadTransferFrame.PayloadChunk( + offset = 0, + flags = 0, + body = frameBytes.toByteString() + ) + ) + ) + ) + writeEncryptedMessage(dataFrame.encode()) + + val lastChunk = OfflineFrame( + version = OfflineFrame.Version.V1, + v1 = V1Frame( + type = V1Frame.FrameType.PAYLOAD_TRANSFER, + payload_transfer = PayloadTransferFrame( + packet_type = PayloadTransferFrame.PacketType.DATA, + payload_header = PayloadHeader( + id = payloadId, + type = PayloadHeader.PayloadType.BYTES, + total_size = frameBytes.size.toLong(), + is_sensitive = false + ), + payload_chunk = PayloadTransferFrame.PayloadChunk( + offset = frameBytes.size.toLong(), + flags = 1 + ) + ) + ) + ) + writeEncryptedMessage(lastChunk.encode()) + } + + private fun serializeEndpointInfo(deviceName: String): ByteArray { + val nameBytes = deviceName.toByteArray(Charsets.UTF_8) + val nameLen = nameBytes.size.coerceAtMost(255) + + val bytes = ByteArray(1 + 16 + 1 + nameLen) + bytes[0] = (1 shl 1).toByte() // deviceType=phone(1) << 1 | visibility(0) | version(0) + val randomBytes = ByteArray(16).also { java.security.SecureRandom().nextBytes(it) } + System.arraycopy(randomBytes, 0, bytes, 1, 16) + bytes[17] = nameLen.toByte() + System.arraycopy(nameBytes, 0, bytes, 18, nameLen) + return bytes + } + + private fun randomEndpointId(): String { + val alphabet = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ" + return (1..4).map { alphabet.random() }.joinToString("") + } + + private fun randomBytes(n: Int) = + ByteArray(n).also { java.security.SecureRandom().nextBytes(it) }.toByteString() + + fun closeConnection() { + isRunning = false + close() + try { + socket.close() + } catch (e: Exception) { + // Ignore + } + executor.shutdownNow() + } +} diff --git a/app/src/main/java/com/sameerasw/airsync/quickshare/QuickShareDiscovery.kt b/app/src/main/java/com/sameerasw/airsync/quickshare/QuickShareDiscovery.kt new file mode 100644 index 00000000..af878521 --- /dev/null +++ b/app/src/main/java/com/sameerasw/airsync/quickshare/QuickShareDiscovery.kt @@ -0,0 +1,147 @@ +package com.sameerasw.airsync.quickshare + +import android.content.Context +import android.net.nsd.NsdManager +import android.net.nsd.NsdServiceInfo +import android.os.Build +import android.util.Base64 +import android.util.Log +import java.net.InetAddress +import java.nio.charset.StandardCharsets + +class QuickShareDiscovery(private val context: Context) { + companion object { + private const val TAG = "QuickShareDiscovery" + private const val SERVICE_TYPE = "_FC9F5ED42C8A._tcp." + + fun decodeDeviceName(endpointInfoBase64: String): String? { + return try { + val bytes = Base64.decode( + endpointInfoBase64, + Base64.URL_SAFE or Base64.NO_WRAP or Base64.NO_PADDING + ) + if (bytes.size < 18) return null + val nameLen = bytes[17].toInt() and 0xFF + if (bytes.size < 18 + nameLen) return null + String(bytes, 18, nameLen, StandardCharsets.UTF_8) + } catch (e: Exception) { + Log.e(TAG, "Failed to decode endpoint info", e) + null + } + } + } + + data class ResolvedHost(val host: InetAddress, val port: Int, val deviceName: String) + + private val nsdManager = context.getSystemService(Context.NSD_SERVICE) as NsdManager + private var discoveryListener: NsdManager.DiscoveryListener? = null + + fun start(targetDeviceName: String, onFound: (ResolvedHost) -> Unit) { + stop() + + val normalizedTarget = targetDeviceName.replace("’", "'").lowercase() + + discoveryListener = object : NsdManager.DiscoveryListener { + override fun onDiscoveryStarted(serviceType: String) { + Log.d(TAG, "Discovery started") + } + + override fun onServiceFound(service: NsdServiceInfo) { + Log.d(TAG, "Service found: ${service.serviceName}") + resolve(service, normalizedTarget, onFound) + } + + override fun onServiceLost(service: NsdServiceInfo) { + Log.d(TAG, "Service lost: ${service.serviceName}") + } + + override fun onDiscoveryStopped(serviceType: String) { + Log.d(TAG, "Discovery stopped") + } + + override fun onStartDiscoveryFailed(serviceType: String, errorCode: Int) { + Log.e(TAG, "Start discovery failed: $errorCode") + } + + override fun onStopDiscoveryFailed(serviceType: String, errorCode: Int) { + Log.e(TAG, "Stop discovery failed: $errorCode") + } + } + + try { + nsdManager.discoverServices(SERVICE_TYPE, NsdManager.PROTOCOL_DNS_SD, discoveryListener) + } catch (e: Exception) { + Log.e(TAG, "Failed to start discovery", e) + } + } + + private fun resolve( + service: NsdServiceInfo, + normalizedTarget: String, + onFound: (ResolvedHost) -> Unit + ) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + nsdManager.registerServiceInfoCallback( + service, + context.mainExecutor, + object : NsdManager.ServiceInfoCallback { + override fun onServiceInfoCallbackRegistrationFailed(errorCode: Int) { + Log.e(TAG, "Resolve registration failed: $errorCode") + } + + override fun onServiceUpdated(info: NsdServiceInfo) { + handleResolved(info, normalizedTarget, onFound) + try { + nsdManager.unregisterServiceInfoCallback(this) + } catch (e: Exception) { + // Ignore + } + } + + override fun onServiceLost() {} + override fun onServiceInfoCallbackUnregistered() {} + } + ) + } else { + @Suppress("DEPRECATION") + nsdManager.resolveService(service, object : NsdManager.ResolveListener { + override fun onResolveFailed(info: NsdServiceInfo, errorCode: Int) { + Log.e(TAG, "Resolve failed: $errorCode") + } + + override fun onServiceResolved(info: NsdServiceInfo) { + handleResolved(info, normalizedTarget, onFound) + } + }) + } + } + + private fun handleResolved( + info: NsdServiceInfo, + normalizedTarget: String, + onFound: (ResolvedHost) -> Unit + ) { + val endpointInfoBytes = info.attributes["n"] ?: return + val endpointInfoBase64 = String(endpointInfoBytes, StandardCharsets.UTF_8) + val deviceName = decodeDeviceName(endpointInfoBase64) ?: return + val normalizedFound = deviceName.replace("’", "'").lowercase() + + Log.d(TAG, "Resolved '$deviceName' at ${info.host}:${info.port}") + + if (normalizedFound == normalizedTarget) { + val host = info.host ?: return + onFound(ResolvedHost(host, info.port, deviceName)) + } + } + + fun stop() { + discoveryListener?.let { + try { + nsdManager.stopServiceDiscovery(it) + } catch (e: Exception) { + // Ignore + } + } + discoveryListener = null + } +} diff --git a/app/src/main/java/com/sameerasw/airsync/quickshare/Ukey2Context.kt b/app/src/main/java/com/sameerasw/airsync/quickshare/Ukey2Context.kt index a0e884ba..3ea5f5aa 100644 --- a/app/src/main/java/com/sameerasw/airsync/quickshare/Ukey2Context.kt +++ b/app/src/main/java/com/sameerasw/airsync/quickshare/Ukey2Context.kt @@ -178,6 +178,83 @@ class Ukey2Context { sendHmacKey = hkdf(d2dServerKey, smsgSalt, "SIG:1".toByteArray()) } + fun buildClientInit(): Ukey2ClientInit { + val ourPubKey = GenericPublicKey( + type = PublicKeyType.EC_P256, + ec_p256_public_key = EcP256PublicKey( + x = encodePoint(keyPair.public as ECPublicKey).first.toByteString(), + y = encodePoint(keyPair.public as ECPublicKey).second.toByteString() + ) + ) + val clientFinished = Ukey2ClientFinished(public_key = ourPubKey.encode().toByteString()) + val clientFinishEnvelope = com.google.security.cryptauth.lib.securegcm.Ukey2Message( + message_type = com.google.security.cryptauth.lib.securegcm.Ukey2Message.Type.CLIENT_FINISH, + message_data = clientFinished.encode().toByteString() + ) + clientFinishEnvelopeBytes = clientFinishEnvelope.encode() + + val digest = SHA512Digest() + digest.update(clientFinishEnvelopeBytes!!, 0, clientFinishEnvelopeBytes!!.size) + val commitment = ByteArray(digest.digestSize) + digest.doFinal(commitment, 0) + + return Ukey2ClientInit( + version = 1, + random = serverRandom.toByteString(), + cipher_commitments = listOf( + Ukey2ClientInit.CipherCommitment( + handshake_cipher = Ukey2HandshakeCipher.P256_SHA512, + commitment = commitment.toByteString() + ) + ), + next_protocol = "AES_256_CBC-HMAC_SHA256" + ) + } + + var clientFinishEnvelopeBytes: ByteArray? = null + private set + + /** + * Processes the server's ServerInit: derives the shared secret via ECDH against our keypair, + * then the same HKDF chain as the server role, but with encrypt/decrypt keys swapped since + * we're the client this time. + * + * @param clientInitEnvelopeBytes Raw bytes of the ClientInit envelope we sent + * @param serverInitEnvelopeBytes Raw bytes of the ServerInit envelope we received + */ + fun handleServerInit( + serverInit: Ukey2ServerInit, + clientInitEnvelopeBytes: ByteArray, + serverInitEnvelopeBytes: ByteArray + ) { + val serverPubKeyProto = GenericPublicKey.ADAPTER.decode(serverInit.public_key!!) + val serverPubKey = decodePublicKey(serverPubKeyProto.ec_p256_public_key!!) + + val ka = KeyAgreement.getInstance("ECDH") + ka.init(keyPair.private) + ka.doPhase(serverPubKey, true) + val dhs = ka.generateSecret() + + val sha256 = java.security.MessageDigest.getInstance("SHA-256") + val derivedSecretKey = sha256.digest(dhs) + + val ukeyInfo = clientInitEnvelopeBytes + serverInitEnvelopeBytes + val authKey = hkdf(derivedSecretKey, "UKEY2 v1 auth".toByteArray(), ukeyInfo) + val nextSecret = hkdf(derivedSecretKey, "UKEY2 v1 next".toByteArray(), ukeyInfo) + + authString = generatePinCode(authKey) + + val d2dClientKey = hkdf(nextSecret, D2D_SALT, "client".toByteArray()) + val d2dServerKey = hkdf(nextSecret, D2D_SALT, "server".toByteArray()) + + val smsgSalt = sha256.digest("SecureMessage".toByteArray()) + + encryptKey = hkdf(d2dClientKey, smsgSalt, "ENC:2".toByteArray()) + sendHmacKey = hkdf(d2dClientKey, smsgSalt, "SIG:1".toByteArray()) + decryptKey = hkdf(d2dServerKey, smsgSalt, "ENC:2".toByteArray()) + receiveHmacKey = hkdf(d2dServerKey, smsgSalt, "SIG:1".toByteArray()) + } + private fun hkdf( key: ByteArray, salt: ByteArray, 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 2de351a0..67b3476b 100644 --- a/app/src/main/java/com/sameerasw/airsync/service/AirSyncTileService.kt +++ b/app/src/main/java/com/sameerasw/airsync/service/AirSyncTileService.kt @@ -115,9 +115,7 @@ class AirSyncTileService : TileService() { dataStoreManager.setUserManuallyDisconnected(true) updateTileState() } else if (isConnected) { - // Mark manual disconnect BEFORE disconnecting so listeners won't schedule auto-reconnect - dataStoreManager.setUserManuallyDisconnected(true) - WebSocketUtil.disconnect(this@AirSyncTileService) + WebSocketUtil.disconnect(this@AirSyncTileService, manual = true) updateTileState() } else { dataStoreManager.setUserManuallyDisconnected(false) diff --git a/app/src/main/java/com/sameerasw/airsync/service/NotificationActionReceiver.kt b/app/src/main/java/com/sameerasw/airsync/service/NotificationActionReceiver.kt index 4d7a376a..ad43a86d 100644 --- a/app/src/main/java/com/sameerasw/airsync/service/NotificationActionReceiver.kt +++ b/app/src/main/java/com/sameerasw/airsync/service/NotificationActionReceiver.kt @@ -42,7 +42,7 @@ class NotificationActionReceiver : BroadcastReceiver() { AirSyncService.ACTION_DISCONNECT -> { Log.d(TAG, "Disconnecting from notification") - WebSocketUtil.disconnect(context) + WebSocketUtil.disconnect(context, manual = true) } ACTION_CANCEL_TRANSFER -> { diff --git a/app/src/main/java/com/sameerasw/airsync/service/OutboundQuickShareService.kt b/app/src/main/java/com/sameerasw/airsync/service/OutboundQuickShareService.kt new file mode 100644 index 00000000..c8202805 --- /dev/null +++ b/app/src/main/java/com/sameerasw/airsync/service/OutboundQuickShareService.kt @@ -0,0 +1,314 @@ +package com.sameerasw.airsync.service + +import android.app.Notification +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.Service +import android.content.Context +import android.content.Intent +import android.content.pm.ServiceInfo +import android.net.Uri +import android.os.Build +import android.os.IBinder +import android.provider.OpenableColumns +import android.util.Log +import androidx.core.app.NotificationCompat +import com.sameerasw.airsync.R +import com.sameerasw.airsync.data.local.DataStoreManager +import com.sameerasw.airsync.quickshare.OutboundQuickShareConnection +import com.sameerasw.airsync.quickshare.QuickShareDiscovery +import com.sameerasw.airsync.utils.NotificationUtil +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import java.io.File +import java.net.Socket + +/** + * Foreground service that sends one or more files to the paired Mac via Quick Share, initiated + * from within the AirSync app (share-sheet target) rather than Android's system Quick Share. + * Mirrors QuickShareService's structure, but drives OutboundQuickShareConnection instead. + */ +class OutboundQuickShareService : Service() { + + companion object { + private const val TAG = "OutboundQuickShare" + private const val NOTIFICATION_ID = 2101 + private const val CHANNEL_ID = "quick_share_send_channel" + private const val DISCOVERY_TIMEOUT_MS = 15_000L + + const val EXTRA_URIS = "uris" + + fun start(context: Context, uris: List) { + val intent = Intent(context, OutboundQuickShareService::class.java).apply { + putParcelableArrayListExtra(EXTRA_URIS, ArrayList(uris)) + } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + context.startForegroundService(intent) + } else { + context.startService(intent) + } + } + } + + private lateinit var dataStoreManager: DataStoreManager + private val serviceScope = CoroutineScope(Dispatchers.IO) + private var discovery: QuickShareDiscovery? = null + private var connection: OutboundQuickShareConnection? = null + @Volatile + private var connecting = false + + override fun onCreate() { + super.onCreate() + createNotificationChannel() + dataStoreManager = DataStoreManager.getInstance(this) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + startForeground( + NOTIFICATION_ID, + createNotification("Preparing to send..."), + ServiceInfo.FOREGROUND_SERVICE_TYPE_CONNECTED_DEVICE + ) + } else { + startForeground(NOTIFICATION_ID, createNotification("Preparing to send...")) + } + } + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + val uris: List = intent?.getParcelableArrayListExtra(EXTRA_URIS) ?: emptyList() + if (uris.isEmpty()) { + Log.w(TAG, "No files to send, stopping") + stopSelf() + return START_NOT_STICKY + } + + serviceScope.launch { + val pairedDevice = dataStoreManager.getLastConnectedDevice().first() + if (pairedDevice == null) { + Log.w(TAG, "No paired Mac, cannot send") + updateNotification("No paired Mac found") + stopSelf() + return@launch + } + + // Must match SyncManager's fallback chain exactly — this is what the Mac stored as + // AppState.shared.device?.name during pairing, and its Quick Share auto-accept does + // an exact string match against it (no normalization), so any divergence here means + // it silently falls through to a manual consent notification instead of auto-accepting. + val ourName = dataStoreManager.getDeviceName().first().ifBlank { null } + ?: com.sameerasw.airsync.utils.DeviceInfoUtil.getDeviceName(this@OutboundQuickShareService) + val files = uris.mapNotNull { toPendingFile(it) } + if (files.isEmpty()) { + Log.w(TAG, "Could not read any of the shared files") + updateNotification("Couldn't read the selected file(s)") + stopSelf() + return@launch + } + + updateNotification("Looking for ${pairedDevice.name}...") + findAndSend(pairedDevice.name, ourName, files) + } + + return START_NOT_STICKY + } + + private fun findAndSend( + macName: String, + ourName: String, + files: List + ) { + val d = QuickShareDiscovery(this) + discovery = d + + val timeoutJob = serviceScope.launch { + kotlinx.coroutines.delay(DISCOVERY_TIMEOUT_MS) + if (connection == null) { + Log.w(TAG, "Discovery timed out") + updateNotification("Couldn't find $macName") + d.stop() + kotlinx.coroutines.delay(2000) + stopSelf() + } + } + + d.start(macName) { resolved -> + if (connecting) return@start // already connecting/connected + connecting = true + timeoutJob.cancel() + d.stop() + + // NSD resolve callbacks land on the main thread — do the blocking socket connect + // off of it. + serviceScope.launch { + try { + val socket = Socket(resolved.host, resolved.port) + val conn = OutboundQuickShareConnection( + context = this@OutboundQuickShareService, + socket = socket, + ourDeviceName = ourName, + files = files + ) + connection = conn + wireCallbacks(conn, macName) + conn.start() + } catch (e: Exception) { + Log.e(TAG, "Failed to connect to $macName", e) + updateNotification("Couldn't connect to $macName") + kotlinx.coroutines.delay(2000) + stopSelf() + } + } + } + } + + private fun wireCallbacks(conn: OutboundQuickShareConnection, macName: String) { + conn.onConnectionReady = { pin -> + Log.d(TAG, "Connection ready, PIN: $pin") + updateNotification("Connecting to $macName (PIN $pin)...") + } + + conn.onRejected = { + Log.d(TAG, "Transfer rejected by $macName") + updateNotification("$macName declined the transfer") + serviceScope.launch { + kotlinx.coroutines.delay(2000) + stopSelf() + } + } + + var lastUpdate = 0L + conn.onFileProgress = { fileName, percent, bytesTransferred, totalSize, transferId -> + val now = System.currentTimeMillis() + if (now - lastUpdate > 800) { + lastUpdate = now + NotificationUtil.showFileProgress( + this, + transferId.hashCode(), + fileName, + percent, + transferId, + isSending = true + ) + } + } + + conn.onFileComplete = { fileName, transferId, success -> + NotificationUtil.showFileComplete( + this, + transferId.hashCode(), + fileName, + success, + isSending = true + ) + } + + conn.onError = { e -> + Log.e(TAG, "Transfer error", e) + updateNotification("Failed to send to $macName") + } + + conn.onFinished = { + Log.d(TAG, "Transfer to $macName finished") + updateNotification("Sent to $macName") + serviceScope.launch { + kotlinx.coroutines.delay(1500) + stopSelf() + } + } + } + + private fun toPendingFile(uri: Uri): OutboundQuickShareConnection.PendingFile? { + return try { + // Files we cached ourselves (see QuickShareSendActivity.copyUrisToCache) — a plain + // java.io.File is always the accurate source of truth for these, and ContentResolver + // has no provider registered for the "file" scheme to query metadata from anyway. + if (uri.scheme == "file") { + val f = java.io.File(uri.path ?: return null) + if (!f.exists() || f.length() <= 0) { + Log.w(TAG, "Cached file missing or empty: $uri") + return null + } + return OutboundQuickShareConnection.PendingFile( + uri = uri, + name = f.name, + mimeType = contentResolver.getType(uri) ?: "application/octet-stream", + size = f.length() + ) + } + + var name = "file" + var size = 0L + contentResolver.query(uri, null, null, null, null)?.use { cursor -> + val nameIdx = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME) + val sizeIdx = cursor.getColumnIndex(OpenableColumns.SIZE) + if (cursor.moveToFirst()) { + if (nameIdx >= 0) name = cursor.getString(nameIdx) ?: name + if (sizeIdx >= 0) size = cursor.getLong(sizeIdx) + } + } + + // OpenableColumns.SIZE is unreliable for some providers (returns 0/null) — the Mac + // hard-rejects any chunk that would exceed the size we declared in FileMetadata, so + // a wrong-but-truthy 0 here breaks the very first real chunk. Fall back to the AFD's + // stat size, which reflects the real underlying file. + if (size <= 0) { + size = try { + contentResolver.openAssetFileDescriptor(uri, "r")?.use { it.length } ?: -1L + } catch (e: Exception) { + -1L + } + } + if (size <= 0) { + Log.w(TAG, "Could not determine size for $uri, skipping") + return null + } + + val mimeType = contentResolver.getType(uri) ?: "application/octet-stream" + OutboundQuickShareConnection.PendingFile( + uri = uri, + name = name, + mimeType = mimeType, + size = size + ) + } catch (e: Exception) { + Log.e(TAG, "Failed to read metadata for $uri", e) + null + } + } + + private fun createNotificationChannel() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val channel = NotificationChannel( + CHANNEL_ID, + "Quick Share (Sending)", + NotificationManager.IMPORTANCE_LOW + ) + getSystemService(NotificationManager::class.java).createNotificationChannel(channel) + } + } + + private fun updateNotification(content: String) { + getSystemService(NotificationManager::class.java).notify(NOTIFICATION_ID, createNotification(content)) + } + + private fun createNotification(content: String): Notification { + return NotificationCompat.Builder(this, CHANNEL_ID) + .setContentTitle("Sending via AirSync") + .setContentText(content) + .setSmallIcon(R.drawable.ic_laptop_24) + .setOngoing(true) + .setPriority(NotificationCompat.PRIORITY_LOW) + .build() + } + + override fun onBind(intent: Intent?): IBinder? = null + + override fun onDestroy() { + discovery?.stop() + connection?.closeConnection() + // Clean up the cache copies QuickShareSendActivity made (see copyUrisToCache) now that + // this transfer attempt is done, successful or not. + File(cacheDir, "quickshare_outgoing").deleteRecursively() + super.onDestroy() + } +} diff --git a/app/src/main/java/com/sameerasw/airsync/utils/MacModelMapper.kt b/app/src/main/java/com/sameerasw/airsync/utils/MacModelMapper.kt index 69760501..2670c319 100644 --- a/app/src/main/java/com/sameerasw/airsync/utils/MacModelMapper.kt +++ b/app/src/main/java/com/sameerasw/airsync/utils/MacModelMapper.kt @@ -58,6 +58,31 @@ object MacModelMapper { } } + @DrawableRes + fun getShareIconRes(device: ConnectedDevice?): Int { + if (device == null) return R.drawable.ic_device_macbook_air + return getShareIconRes(device.name, device.model, device.deviceType) + } + + @DrawableRes + fun getShareIconRes(name: String, model: String?, deviceType: String?): Int { + val modelStr = model?.replace(" ", "") ?: "" + val nameStr = name.replace(" ", "").lowercase() + val typeStr = deviceType?.replace(" ", "")?.lowercase() ?: "" + val hay = "$nameStr$modelStr$typeStr".lowercase() + + return when { + hay.contains("macbookair") -> R.drawable.ic_device_macbook_air + hay.contains("macbookpro") -> R.drawable.ic_device_macbook_air + hay.contains("macmini") -> R.drawable.ic_device_mac_mini + hay.contains("imac") -> R.drawable.ic_device_imac + hay.contains("macstudio") -> R.drawable.ic_device_mac_studio + hay.contains("macpro") -> R.drawable.ic_device_mac_pro + hay.contains("macbookneo") -> R.drawable.ic_device_macbook_air + else -> R.drawable.ic_device_macbook_air + } + } + @DrawableRes private fun resolveDrawable(model: String, hay: String): Int { // 1) Explicit Model Matching based on user mapping 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 e7d18211..96cccd7c 100644 --- a/app/src/main/java/com/sameerasw/airsync/utils/PermissionUtil.kt +++ b/app/src/main/java/com/sameerasw/airsync/utils/PermissionUtil.kt @@ -181,9 +181,9 @@ object PermissionUtil { missing.add("Background App Usage") } - // Check wallpaper access permission (optional) + // Check wallpaper/file access permission (optional) if (!hasWallpaperAccess()) { - missing.add("Wallpaper Access") + missing.add("Wallpaper & File Access") } // Check call log permission (optional) @@ -249,9 +249,9 @@ object PermissionUtil { optional.add("Background App Usage") } - // Wallpaper access is optional for wallpaper sync feature + // Wallpaper/file access is optional, for wallpaper sync and the Mac File Access (WebDAV) feature if (!hasWallpaperAccess()) { - optional.add("Wallpaper Access") + optional.add("Wallpaper & File Access") } // Call log access is optional for call log sync diff --git a/app/src/main/java/com/sameerasw/airsync/utils/ShortcutUtil.kt b/app/src/main/java/com/sameerasw/airsync/utils/ShortcutUtil.kt index 5b7907f7..a72a001b 100644 --- a/app/src/main/java/com/sameerasw/airsync/utils/ShortcutUtil.kt +++ b/app/src/main/java/com/sameerasw/airsync/utils/ShortcutUtil.kt @@ -8,6 +8,7 @@ import androidx.core.graphics.drawable.IconCompat import com.sameerasw.airsync.R import com.sameerasw.airsync.data.local.DataStoreManager import com.sameerasw.airsync.presentation.ui.activities.ClipboardActionActivity +import com.sameerasw.airsync.presentation.ui.activities.QuickShareSendActivity import kotlinx.coroutines.flow.first import kotlinx.coroutines.runBlocking @@ -20,6 +21,8 @@ object ShortcutUtil { const val SHORTCUT_ID_DISCONNECT = "shortcut_disconnect" const val SHORTCUT_ID_PAUSE = "shortcut_pause" const val SHORTCUT_ID_RESUME = "shortcut_resume" + const val SHORTCUT_ID_SHARE_MAC = "shortcut_share_mac" + const val SHARE_TARGET_CATEGORY_FILE = "com.sameerasw.airsync.categories.SHARE_TARGET_FILE" const val DASH_ACTION_RECONNECT = "com.sameerasw.airsync.ACTION_RECONNECT" const val DASH_ACTION_CLIPBOARD = "com.sameerasw.airsync.ACTION_CLIPBOARD" @@ -94,6 +97,8 @@ object ShortcutUtil { .build() ) } else { + buildShareMacShortcut(context)?.let { shortcuts.add(it) } + // 1. Remote (Direct to Remote tab) // Use MainActivity with a specific action shortcuts.add( @@ -163,6 +168,32 @@ object ShortcutUtil { ShortcutManagerCompat.setDynamicShortcuts(context, shortcuts) } + + private fun buildShareMacShortcut(context: Context): ShortcutInfoCompat? { + val dataStoreManager = DataStoreManager.getInstance(context) + val lastConnectedDevice = runBlocking { + dataStoreManager.getLastConnectedDevice().first() + } ?: return null + + return ShortcutInfoCompat.Builder(context, SHORTCUT_ID_SHARE_MAC) + .setShortLabel(lastConnectedDevice.name) + .setLongLabel(lastConnectedDevice.name) + .setIcon(IconCompat.createWithResource(context, MacModelMapper.getShareIconRes(lastConnectedDevice))) + .setLongLived(true) + .setCategories(setOf(SHARE_TARGET_CATEGORY_FILE)) + .setIntent(Intent(context, QuickShareSendActivity::class.java).apply { + action = Intent.ACTION_SEND + }) + .build() + } + + + fun reportShareMacShortcutUsed(context: Context) { + buildShareMacShortcut(context)?.let { + ShortcutManagerCompat.pushDynamicShortcut(context, it) + } + } + // Legacy method cleanup fun removeShareShortcut(context: Context) { ShortcutManagerCompat.removeAllDynamicShortcuts(context) 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 57b6dc97..ba07ed82 100644 --- a/app/src/main/java/com/sameerasw/airsync/utils/WebSocketMessageHandler.kt +++ b/app/src/main/java/com/sameerasw/airsync/utils/WebSocketMessageHandler.kt @@ -437,15 +437,7 @@ object WebSocketMessageHandler { private fun handleDisconnectRequest(context: Context) { try { - CoroutineScope(Dispatchers.IO).launch { - try { - val dataStoreManager = DataStoreManager(context) - dataStoreManager.setUserManuallyDisconnected(true) - } catch (_: Exception) { - } - } - // Immediately disconnect the WebSocket - WebSocketUtil.disconnect() + WebSocketUtil.disconnect(context, manual = true) Log.d(TAG, "WebSocket disconnected as per request") } catch (e: Exception) { Log.e(TAG, "Error handling disconnect request: ${e.message}") 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 57b95343..7dcac9ea 100644 --- a/app/src/main/java/com/sameerasw/airsync/utils/WebSocketUtil.kt +++ b/app/src/main/java/com/sameerasw/airsync/utils/WebSocketUtil.kt @@ -693,9 +693,19 @@ object WebSocketUtil { /** * Disconnects the WebSocket and cleans up resources. * Stops related services (AirSyncService, periodic sync) and updates UI state. + * + * @param manual True only for a user-initiated disconnect. Persists the manual-disconnect + * flag and tells the Mac so neither side auto-reconnects. Internal/automatic disconnects + * (handshake timeout, network change, socket error) must pass false so auto-reconnect can + * still resume. */ - fun disconnect(context: Context? = null) { - Log.d(TAG, "Disconnecting WebSocket") + fun disconnect(context: Context? = null, manual: Boolean = false) { + Log.d(TAG, "Disconnecting WebSocket (manual=$manual)") + + if (manual) { + sendMessage("{\"type\":\"userDisconnected\",\"data\":{}}") + } + updateConnectedStatus(false) isConnecting.set(false) isSocketOpen.set(false) @@ -703,19 +713,21 @@ object WebSocketUtil { handshakeTimeoutJob?.cancel() currentIpAddress = null - // Set manual disconnect flag val ctx = context ?: appContext - ctx?.let { c -> - CoroutineScope(Dispatchers.IO).launch { - try { - val ds = com.sameerasw.airsync.data.local.DataStoreManager.getInstance(c) - ds.setUserManuallyDisconnected(true) - } catch (_: Exception) { + + if (manual) { + ctx?.let { c -> + CoroutineScope(Dispatchers.IO).launch { + try { + val ds = com.sameerasw.airsync.data.local.DataStoreManager.getInstance(c) + ds.setUserManuallyDisconnected(true) + } catch (_: Exception) { + } } } } - webSocket?.close(1000, "Manual disconnection") + webSocket?.close(1000, if (manual) "Manual disconnection" else "Disconnected") webSocket = null // Transition back to scanning on disconnect @@ -753,7 +765,7 @@ object WebSocketUtil { // Disconnect any active BLE transport connections try { val bleManager = com.sameerasw.airsync.AirSyncApp.getBleConnectionManager() - if (bleManager != null && bleManager.isAuthenticated) { + if (manual && bleManager != null && bleManager.isAuthenticated) { BleTransportBridge.sendManualDisconnect() } bleManager?.disconnectAllConnectedDevices() diff --git a/app/src/main/java/com/sameerasw/airsync/widget/AirSyncWidgetProvider.kt b/app/src/main/java/com/sameerasw/airsync/widget/AirSyncWidgetProvider.kt index 7f0c76b1..55874f43 100644 --- a/app/src/main/java/com/sameerasw/airsync/widget/AirSyncWidgetProvider.kt +++ b/app/src/main/java/com/sameerasw/airsync/widget/AirSyncWidgetProvider.kt @@ -55,11 +55,7 @@ class AirSyncWidgetProvider : AppWidgetProvider() { ACTION_DISCONNECT -> { try { - // Mark manual disconnect and disconnect - runBlocking { - DataStoreManager(context).setUserManuallyDisconnected(true) - } - WebSocketUtil.disconnect(context) + WebSocketUtil.disconnect(context, manual = true) } catch (e: Exception) { Log.e(TAG, "Widget disconnect failed: ${e.message}") } diff --git a/app/src/main/res/drawable/ic_device_imac.png b/app/src/main/res/drawable/ic_device_imac.png index ca09ba0f..4d44d628 100644 Binary files a/app/src/main/res/drawable/ic_device_imac.png and b/app/src/main/res/drawable/ic_device_imac.png differ diff --git a/app/src/main/res/drawable/ic_device_mac_mini.png b/app/src/main/res/drawable/ic_device_mac_mini.png new file mode 100644 index 00000000..3c67f921 Binary files /dev/null and b/app/src/main/res/drawable/ic_device_mac_mini.png differ diff --git a/app/src/main/res/drawable/ic_device_mac_pro.png b/app/src/main/res/drawable/ic_device_mac_pro.png new file mode 100644 index 00000000..51c63fd7 Binary files /dev/null and b/app/src/main/res/drawable/ic_device_mac_pro.png differ diff --git a/app/src/main/res/drawable/ic_device_mac_studio.png b/app/src/main/res/drawable/ic_device_mac_studio.png new file mode 100644 index 00000000..3c67f921 Binary files /dev/null and b/app/src/main/res/drawable/ic_device_mac_studio.png differ diff --git a/app/src/main/res/drawable/ic_device_macbook_air.png b/app/src/main/res/drawable/ic_device_macbook_air.png new file mode 100644 index 00000000..331902ac Binary files /dev/null and b/app/src/main/res/drawable/ic_device_macbook_air.png differ diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 25c272e9..8276e73f 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -25,6 +25,7 @@ Clipboard Sent Failed to Send Clipboard Your Mac + Send Disconnected Paused Pause diff --git a/app/src/main/res/xml/shortcuts.xml b/app/src/main/res/xml/shortcuts.xml index e5ef4db7..08bdd6fc 100644 --- a/app/src/main/res/xml/shortcuts.xml +++ b/app/src/main/res/xml/shortcuts.xml @@ -1,7 +1,7 @@ - - - + + +