diff --git a/app/build.gradle.kts b/app/build.gradle.kts index db4c1464..4f58ffbe 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -154,6 +154,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/src/main/java/com/sameerasw/airsync/data/local/DataStoreManager.kt b/app/src/main/java/com/sameerasw/airsync/data/local/DataStoreManager.kt index 78538e83..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,6 +92,7 @@ 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") @@ -346,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 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/UiState.kt b/app/src/main/java/com/sameerasw/airsync/domain/model/UiState.kt index cb64daeb..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 @@ -50,5 +50,6 @@ data class UiState( val isOnboardingCompleted: Boolean = true, val widgetTransparency: Float = 1f, 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/components/SettingsView.kt b/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/SettingsView.kt index 2af74a68..b16644f4 100644 --- a/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/SettingsView.kt +++ b/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/SettingsView.kt @@ -38,6 +38,7 @@ import androidx.compose.runtime.Composable 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 @@ -242,6 +243,16 @@ fun SettingsView( 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) + } + ) } } 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 ae16cb96..89a034a5 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 @@ -184,6 +184,13 @@ class AirSyncViewModel( } } + // 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 -> @@ -691,6 +698,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 = "") 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..d5391863 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) @@ -234,8 +284,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/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/res/values/strings.xml b/app/src/main/res/values/strings.xml index 6e78a719..52e469b5 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -89,4 +89,6 @@ Attempted when disconnected unexpectedly Switch to Nearby Use Bluetooth LE if connection lost + File Access + Mount storage in macOS Finder \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 894c19be..effcd10d 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -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" }