diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 9417c9d..46c45cf 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -146,6 +146,7 @@ dependencies { implementation(libs.ktor.server.cio) implementation(libs.ktor.server.content.negotiation) implementation(libs.ktor.serialization.kotlinx.json) + implementation(libs.ktor.client.content.negotiation) implementation(libs.coil.compose) implementation(libs.coil.network.ktor3) implementation(libs.ktor.client.android) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 72086a3..ecf14c6 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -9,14 +9,6 @@ - - - - - - - - @@ -67,6 +59,7 @@ android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE" android:value="Local network server for managing deeplinks" /> + \ No newline at end of file diff --git a/app/src/main/java/com/yogeshpaliyal/deepr/DeeprApplication.kt b/app/src/main/java/com/yogeshpaliyal/deepr/DeeprApplication.kt index c7491ed..860ccc6 100644 --- a/app/src/main/java/com/yogeshpaliyal/deepr/DeeprApplication.kt +++ b/app/src/main/java/com/yogeshpaliyal/deepr/DeeprApplication.kt @@ -21,8 +21,12 @@ import com.yogeshpaliyal.deepr.sync.SyncRepository import com.yogeshpaliyal.deepr.sync.SyncRepositoryImpl import com.yogeshpaliyal.deepr.viewmodel.AccountViewModel import com.yogeshpaliyal.deepr.viewmodel.LocalServerViewModel +import com.yogeshpaliyal.deepr.viewmodel.TransferLinkLocalServerViewModel import io.ktor.client.HttpClient import io.ktor.client.engine.cio.CIO +import io.ktor.client.plugins.contentnegotiation.ContentNegotiation +import io.ktor.serialization.kotlinx.json.json +import kotlinx.serialization.json.Json import org.koin.android.ext.koin.androidContext import org.koin.core.context.startKoin import org.koin.core.module.dsl.viewModel @@ -70,7 +74,18 @@ class DeeprApplication : Application() { single { AutoBackupWorker(androidContext(), get(), get()) } single { - HttpClient(CIO) + HttpClient(CIO) { + install(ContentNegotiation) { + // FIX: Explicitly call the Json function from kotlinx.serialization.json + json( + Json { + prettyPrint = true + isLenient = true + ignoreUnknownKeys = true + }, + ) + } + } } viewModel { AccountViewModel(get(), get(), get(), get(), get(), get()) } @@ -84,7 +99,7 @@ class DeeprApplication : Application() { } single { - LocalServerRepositoryImpl(androidContext(), get(), get(), get(), get()) + LocalServerRepositoryImpl(androidContext(), get(), get(), get(), get(), get()) } viewModel { @@ -94,6 +109,10 @@ class DeeprApplication : Application() { single { ReviewManagerFactory.create() } + + viewModel { + TransferLinkLocalServerViewModel(get()) + } } startKoin { diff --git a/app/src/main/java/com/yogeshpaliyal/deepr/MainActivity.kt b/app/src/main/java/com/yogeshpaliyal/deepr/MainActivity.kt index 43a510b..2f8ec10 100644 --- a/app/src/main/java/com/yogeshpaliyal/deepr/MainActivity.kt +++ b/app/src/main/java/com/yogeshpaliyal/deepr/MainActivity.kt @@ -28,6 +28,8 @@ import com.yogeshpaliyal.deepr.ui.screens.LocalNetworkServer import com.yogeshpaliyal.deepr.ui.screens.LocalNetworkServerScreen import com.yogeshpaliyal.deepr.ui.screens.Settings import com.yogeshpaliyal.deepr.ui.screens.SettingsScreen +import com.yogeshpaliyal.deepr.ui.screens.TransferLinkLocalNetworkServer +import com.yogeshpaliyal.deepr.ui.screens.TransferLinkLocalServerScreen import com.yogeshpaliyal.deepr.ui.screens.home.Home import com.yogeshpaliyal.deepr.ui.screens.home.HomeScreen import com.yogeshpaliyal.deepr.ui.theme.DeeprTheme @@ -160,6 +162,11 @@ fun Dashboard( LocalNetworkServerScreen(backStack) } + is TransferLinkLocalNetworkServer -> + NavEntry(key) { + TransferLinkLocalServerScreen(backStack) + } + else -> NavEntry(Unit) { Text("Unknown route") } } }, diff --git a/app/src/main/java/com/yogeshpaliyal/deepr/server/LocalServerRepository.kt b/app/src/main/java/com/yogeshpaliyal/deepr/server/LocalServerRepository.kt index dd41ba7..0e4d40a 100644 --- a/app/src/main/java/com/yogeshpaliyal/deepr/server/LocalServerRepository.kt +++ b/app/src/main/java/com/yogeshpaliyal/deepr/server/LocalServerRepository.kt @@ -6,10 +6,15 @@ interface LocalServerRepository { val isRunning: StateFlow val serverUrl: StateFlow val serverPort: StateFlow + val isTransferLinkServerRunning: StateFlow + val transferLinkServerUrl: StateFlow + val qrCodeData: StateFlow - suspend fun startServer() + suspend fun startServer(port: Int) suspend fun stopServer() suspend fun setServerPort(port: Int) + + suspend fun fetchAndImportFromSender(qrTransferInfo: QRTransferInfo): Result } diff --git a/app/src/main/java/com/yogeshpaliyal/deepr/server/LocalServerRepositoryImpl.kt b/app/src/main/java/com/yogeshpaliyal/deepr/server/LocalServerRepositoryImpl.kt index bc001c7..fac4e30 100644 --- a/app/src/main/java/com/yogeshpaliyal/deepr/server/LocalServerRepositoryImpl.kt +++ b/app/src/main/java/com/yogeshpaliyal/deepr/server/LocalServerRepositoryImpl.kt @@ -3,12 +3,22 @@ package com.yogeshpaliyal.deepr.server import android.content.Context import android.net.wifi.WifiManager import android.util.Log +import com.yogeshpaliyal.deepr.BuildConfig import com.yogeshpaliyal.deepr.DeeprQueries +import com.yogeshpaliyal.deepr.Tags import com.yogeshpaliyal.deepr.data.NetworkRepository import com.yogeshpaliyal.deepr.preference.AppPreferenceDataStore import com.yogeshpaliyal.deepr.viewmodel.AccountViewModel +import io.ktor.client.HttpClient +import io.ktor.client.call.body +import io.ktor.client.plugins.timeout +import io.ktor.client.request.get +import io.ktor.client.statement.HttpResponse import io.ktor.http.ContentType import io.ktor.http.HttpStatusCode +import io.ktor.http.URLProtocol +import io.ktor.http.isSuccess +import io.ktor.http.path import io.ktor.serialization.kotlinx.json.json import io.ktor.server.application.install import io.ktor.server.cio.CIO @@ -27,7 +37,9 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import kotlinx.serialization.Serializable import kotlinx.serialization.json.Json import java.net.NetworkInterface @@ -36,11 +48,13 @@ import java.util.Locale class LocalServerRepositoryImpl( private val context: Context, private val deeprQueries: DeeprQueries, + private val httpClient: HttpClient, private val accountViewModel: AccountViewModel, private val networkRepository: NetworkRepository, private val preferenceDataStore: AppPreferenceDataStore, ) : LocalServerRepository { - private var server: EmbeddedServer? = null + private var server: EmbeddedServer? = + null private val _isRunning = MutableStateFlow(false) override val isRunning: StateFlow = _isRunning.asStateFlow() @@ -50,6 +64,16 @@ class LocalServerRepositoryImpl( private val _serverPort = MutableStateFlow(8080) override val serverPort: StateFlow = _serverPort.asStateFlow() + private val _qrCodeData = MutableStateFlow(null) + override val qrCodeData: StateFlow = _qrCodeData + + private val _isTransferLinkServerRunning = MutableStateFlow(false) + override val isTransferLinkServerRunning: StateFlow = + _isTransferLinkServerRunning.asStateFlow() + + private val _transferLinkServerUrl = MutableStateFlow(null) + override val transferLinkServerUrl: StateFlow = _transferLinkServerUrl.asStateFlow() + init { // Load saved port on initialization CoroutineScope(Dispatchers.IO).launch { @@ -71,8 +95,11 @@ class LocalServerRepositoryImpl( } } - override suspend fun startServer() { - if (_isRunning.value) { + override suspend fun startServer(port: Int) { + if (isRunning.value || isTransferLinkServerRunning.value) { + if (port == 9000) { + generateQRCode(port)?.let { qrData -> _qrCodeData.update { qrData } } + } Log.d("LocalServer", "Server is already running") return } @@ -150,13 +177,19 @@ class LocalServerRepositoryImpl( createdAt = link.createdAt, openedCount = link.openedCount, notes = link.notes, - tags = link.tagsNames?.split(", ")?.filter { it.isNotEmpty() } ?: emptyList(), + tags = + link.tagsNames + ?.split(", ") + ?.filter { it.isNotEmpty() } ?: emptyList(), ) } call.respond(HttpStatusCode.OK, response) } catch (e: Exception) { Log.e("LocalServer", "Error getting links", e) - call.respond(HttpStatusCode.InternalServerError, ErrorResponse("Error getting links: ${e.message}")) + call.respond( + HttpStatusCode.InternalServerError, + ErrorResponse("Error getting links: ${e.message}"), + ) } } @@ -171,10 +204,16 @@ class LocalServerRepositoryImpl( request.tags.map { it.toDbTag() }, request.notes, ) - call.respond(HttpStatusCode.Created, SuccessResponse("Link added successfully")) + call.respond( + HttpStatusCode.Created, + SuccessResponse("Link added successfully"), + ) } catch (e: Exception) { Log.e("LocalServer", "Error adding link", e) - call.respond(HttpStatusCode.InternalServerError, ErrorResponse("Error adding link: ${e.message}")) + call.respond( + HttpStatusCode.InternalServerError, + ErrorResponse("Error adding link: ${e.message}"), + ) } } @@ -211,7 +250,10 @@ class LocalServerRepositoryImpl( call.respond(HttpStatusCode.OK, response) } catch (e: Exception) { Log.e("LocalServer", "Error getting tags", e) - call.respond(HttpStatusCode.InternalServerError, ErrorResponse("Error getting tags: ${e.message}")) + call.respond( + HttpStatusCode.InternalServerError, + ErrorResponse("Error getting tags: ${e.message}"), + ) } } @@ -219,7 +261,10 @@ class LocalServerRepositoryImpl( try { val url = call.request.queryParameters["url"] if (url.isNullOrBlank()) { - call.respond(HttpStatusCode.BadRequest, ErrorResponse("URL parameter is required")) + call.respond( + HttpStatusCode.BadRequest, + ErrorResponse("URL parameter is required"), + ) return@get } @@ -241,20 +286,36 @@ class LocalServerRepositoryImpl( } } catch (e: Exception) { Log.e("LocalServer", "Error getting link info", e) - call.respond(HttpStatusCode.InternalServerError, ErrorResponse("Error getting link info: ${e.message}")) + call.respond( + HttpStatusCode.InternalServerError, + ErrorResponse("Error getting link info: ${e.message}"), + ) } } } } server?.start(wait = false) - _isRunning.value = true - _serverUrl.value = "http://$ipAddress:$port" - Log.d("LocalServer", "Server started at ${_serverUrl.value}") + if (port == 9000) { + val generatedQrData = generateQRCode(port) + _qrCodeData.update { generatedQrData } + _isTransferLinkServerRunning.update { true } + _transferLinkServerUrl.update { "http://$ipAddress:$port" } + Log.d("LocalServer", "Server started at ${_transferLinkServerUrl.value}") + } else { + _isRunning.update { true } + _serverUrl.update { "http://$ipAddress:$port" } + Log.d("LocalServer", "Server started at ${_serverUrl.value}") + } } catch (e: Exception) { Log.e("LocalServer", "Error starting server", e) - _isRunning.value = false - _serverUrl.value = null + if (port == 9000) { + _isTransferLinkServerRunning.update { false } + _transferLinkServerUrl.update { null } + } else { + _isRunning.update { false } + _serverUrl.update { null } + } } } @@ -262,18 +323,114 @@ class LocalServerRepositoryImpl( try { server?.stop(1000, 2000) server = null - _isRunning.value = false - _serverUrl.value = null + _isRunning.update { false } + _serverUrl.update { null } + _isTransferLinkServerRunning.update { false } + _transferLinkServerUrl.update { null } Log.d("LocalServer", "Server stopped") } catch (e: Exception) { Log.e("LocalServer", "Error stopping server", e) } } + override suspend fun fetchAndImportFromSender(qrTransferInfo: QRTransferInfo): Result { + return withContext(Dispatchers.IO) { + try { + val response: HttpResponse = + httpClient.get { + url { + protocol = URLProtocol.HTTP + host = qrTransferInfo.ip + port = qrTransferInfo.port + path("api/export") + } + timeout { + requestTimeoutMillis = 30000 // 30 seconds + } + } + + Log.d("Anas", response.toString()) + + if (response.status.isSuccess().not()) { + return@withContext Result.failure( + Exception("Failed to fetch data: ${response.status}"), + ) + } + + val exportedData: ExportedData = response.body() + + importToDatabase(exportedData) + + Result.success(Unit) + } catch (e: Exception) { + Result.failure(e) + } + } + } + + private fun importToDatabase(data: ExportedData) { + deeprQueries.transaction { + data.links.forEach { deeplink -> + if (deeprQueries.getDeeprByLink(deeplink.link).executeAsList().isEmpty()) { + deeprQueries.insertDeepr( + link = deeplink.link, + name = deeplink.name, + openedCount = deeplink.openedCount, + notes = deeplink.notes, + thumbnail = deeplink.thumbnail, + ) + + val insertedId = deeprQueries.lastInsertRowId().executeAsOne() + + deeplink.tags.forEach { tagName -> + deeprQueries.insertTag(name = tagName) + + val tag = deeprQueries.getTagByName(tagName).executeAsOne() + + deeprQueries.addTagToLink( + linkId = insertedId, + tagId = tag.id, + ) + } + + if (deeplink.isFavourite) { + deeprQueries.setFavourite( + isFavourite = 1, + id = insertedId, + ) + } + } + } + + data.tags.forEach { tagName -> + deeprQueries.insertTag(name = tagName) + } + } + } + + private fun generateQRCode(port: Int): String? { + val ipAddress = getIpAddress() ?: return null + + val qrInfo = + QRTransferInfo( + ip = ipAddress, + port = port, + appVersion = BuildConfig.VERSION_NAME, + ) + + return try { + Json.encodeToString(QRTransferInfo.serializer(), qrInfo) + } catch (e: Exception) { + Log.e("LocalServer", "Error generating QR code data", e) + null + } + } + private fun getIpAddress(): String? { try { // Try to get WiFi IP first - val wifiManager = context.applicationContext.getSystemService(Context.WIFI_SERVICE) as? WifiManager + val wifiManager = + context.applicationContext.getSystemService(Context.WIFI_SERVICE) as? WifiManager wifiManager?.connectionInfo?.ipAddress?.let { ipInt -> if (ipInt != 0) { return String.format( @@ -322,7 +479,7 @@ data class TagData( val id: Long, val name: String, ) { - fun toDbTag() = com.yogeshpaliyal.deepr.Tags(id, name) + fun toDbTag() = Tags(id, name) } @Serializable @@ -355,3 +512,29 @@ data class TagResponse( val name: String, val count: Int, ) + +@Serializable +data class QRTransferInfo( + val ip: String, + val port: Int, + val appVersion: String, +) + +@Serializable +data class ExportedData( + val links: List, + val tags: List, + val exportedAt: Long, +) + +@Serializable +data class ExportedDeeplink( + val link: String, + val name: String, + val notes: String, + val tags: List, + val openedCount: Long, + val isFavourite: Boolean, + val createdAt: String, + val thumbnail: String, +) diff --git a/app/src/main/java/com/yogeshpaliyal/deepr/server/LocalServerService.kt b/app/src/main/java/com/yogeshpaliyal/deepr/server/LocalServerService.kt index 6a5c6ee..9d67685 100644 --- a/app/src/main/java/com/yogeshpaliyal/deepr/server/LocalServerService.kt +++ b/app/src/main/java/com/yogeshpaliyal/deepr/server/LocalServerService.kt @@ -20,6 +20,8 @@ import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch import org.koin.android.ext.android.inject +const val PORT = "port" + class LocalServerService : Service() { private val localServerRepository: LocalServerRepository by inject() private val serviceScope = CoroutineScope(SupervisorJob() + Dispatchers.Main) @@ -38,8 +40,9 @@ class LocalServerService : Service() { ACTION_START -> { // Start foreground immediately to avoid ANR startForeground(NOTIFICATION_ID, createNotification(null)) + val port = intent.getIntExtra(PORT, 8080) serviceScope.launch { - localServerRepository.startServer() + localServerRepository.startServer(port) observeServerState() } } @@ -66,6 +69,16 @@ class LocalServerService : Service() { } } } + serviceScope.launch { + localServerRepository.isTransferLinkServerRunning.collect { isRunning -> + if (isRunning) { + val serverUrl = localServerRepository.transferLinkServerUrl.first() + val notificationManager = + getSystemService(NOTIFICATION_SERVICE) as NotificationManager + notificationManager.notify(NOTIFICATION_ID, createNotification(serverUrl)) + } + } + } } private fun createNotificationChannel() { @@ -139,10 +152,14 @@ class LocalServerService : Service() { const val ACTION_START = "com.yogeshpaliyal.deepr.ACTION_START_SERVER" const val ACTION_STOP = "com.yogeshpaliyal.deepr.ACTION_STOP_SERVER" - fun startService(context: Context) { + fun startService( + context: Context, + port: Int, + ) { val intent = Intent(context, LocalServerService::class.java).apply { action = ACTION_START + putExtra(PORT, port) } if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { context.startForegroundService(intent) diff --git a/app/src/main/java/com/yogeshpaliyal/deepr/ui/screens/LocalNetworkServer.kt b/app/src/main/java/com/yogeshpaliyal/deepr/ui/screens/LocalNetworkServer.kt index 79f95eb..d657876 100644 --- a/app/src/main/java/com/yogeshpaliyal/deepr/ui/screens/LocalNetworkServer.kt +++ b/app/src/main/java/com/yogeshpaliyal/deepr/ui/screens/LocalNetworkServer.kt @@ -51,7 +51,6 @@ import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp @@ -105,7 +104,7 @@ fun LocalNetworkServerScreen( rememberPermissionState(Manifest.permission.POST_NOTIFICATIONS) { if (pendingStart) { pendingStart = false - LocalServerService.startService(context) + LocalServerService.startService(context = context, port = 8080) } } } else { @@ -399,7 +398,13 @@ fun LocalNetworkServerScreen( Toast .makeText( context, - if (isRunning) context.getString(R.string.port_changed_restart) else context.getString(R.string.saved), + if (isRunning) { + context.getString(R.string.port_changed_restart) + } else { + context.getString( + R.string.saved, + ) + }, Toast.LENGTH_SHORT, ).show() } else { @@ -419,7 +424,6 @@ fun LocalNetworkServerScreen( } } -@Preview() @OptIn(ExperimentalPermissionsApi::class) @Composable private fun ServerSwitch( @@ -444,7 +448,10 @@ private fun ServerSwitch( Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp), - modifier = Modifier.fillMaxWidth().padding(8.dp), + modifier = + Modifier + .fillMaxWidth() + .padding(8.dp), ) { Box( modifier = @@ -507,7 +514,7 @@ private fun ServerSwitch( setPendingStart(true) notificationPermissionState.launchPermissionRequest() } else { - LocalServerService.startService(context) + LocalServerService.startService(context = context, port = 8080) } } else { LocalServerService.stopService(context) @@ -675,7 +682,10 @@ private fun PortConfigurationCard( Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp), - modifier = Modifier.fillMaxWidth().padding(8.dp), + modifier = + Modifier + .fillMaxWidth() + .padding(8.dp), ) { Icon( TablerIcons.Server, diff --git a/app/src/main/java/com/yogeshpaliyal/deepr/ui/screens/Settings.kt b/app/src/main/java/com/yogeshpaliyal/deepr/ui/screens/Settings.kt index 8657201..d25b93f 100644 --- a/app/src/main/java/com/yogeshpaliyal/deepr/ui/screens/Settings.kt +++ b/app/src/main/java/com/yogeshpaliyal/deepr/ui/screens/Settings.kt @@ -94,7 +94,11 @@ fun SettingsScreen( val availableImporters = remember { viewModel.getAvailableImporters() } // Track which importer is being used for the current file picker - var selectedImporter by remember { mutableStateOf(null) } + var selectedImporter by remember { + mutableStateOf( + null, + ) + } // Launcher for picking files to import val importFileLauncher = @@ -268,6 +272,13 @@ fun SettingsScreen( csvExportLauncher.launch("deepr_export_$timeStamp.csv") }, ) + SettingsItem( + TablerIcons.Server, + title = stringResource(R.string.transfer_link_to_another_device), + onClick = { + backStack.add(TransferLinkLocalNetworkServer) + }, + ) } SettingsSection("Local File Sync") { diff --git a/app/src/main/java/com/yogeshpaliyal/deepr/ui/screens/TransferLinkLocalServer.kt b/app/src/main/java/com/yogeshpaliyal/deepr/ui/screens/TransferLinkLocalServer.kt new file mode 100644 index 0000000..f8be78e --- /dev/null +++ b/app/src/main/java/com/yogeshpaliyal/deepr/ui/screens/TransferLinkLocalServer.kt @@ -0,0 +1,387 @@ +package com.yogeshpaliyal.deepr.ui.screens + +import android.Manifest +import android.content.ClipData +import android.content.ClipboardManager +import android.content.Context +import android.os.Build +import android.widget.Toast +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.background +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.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshots.SnapshotStateList +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.scale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.LayoutDirection +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.google.accompanist.permissions.ExperimentalPermissionsApi +import com.google.accompanist.permissions.isGranted +import com.google.accompanist.permissions.rememberPermissionState +import com.journeyapps.barcodescanner.ScanOptions +import com.lightspark.composeqr.QrCodeView +import com.yogeshpaliyal.deepr.R +import com.yogeshpaliyal.deepr.server.LocalServerService +import com.yogeshpaliyal.deepr.util.QRScanner +import com.yogeshpaliyal.deepr.viewmodel.TransferLinkLocalServerViewModel +import compose.icons.TablerIcons +import compose.icons.tablericons.ArrowLeft +import compose.icons.tablericons.Copy +import compose.icons.tablericons.Scan +import compose.icons.tablericons.Server +import kotlinx.coroutines.flow.collectLatest +import org.koin.androidx.compose.koinViewModel + +data object TransferLinkLocalNetworkServer + +@OptIn(ExperimentalPermissionsApi::class, ExperimentalMaterial3Api::class) +@Composable +fun TransferLinkLocalServerScreen( + backStack: SnapshotStateList, + modifier: Modifier = Modifier, + viewModel: TransferLinkLocalServerViewModel = koinViewModel(), +) { + val context = LocalContext.current + val isRunning by viewModel.isRunning.collectAsStateWithLifecycle() + val serverUrl by viewModel.serverUrl.collectAsStateWithLifecycle() + val qrCodeData by viewModel.qrCodeData.collectAsStateWithLifecycle() + val isRtl = LocalLayoutDirection.current == LayoutDirection.Rtl + + LaunchedEffect(true) { + viewModel.transferResultFlow.collectLatest { message -> + Toast.makeText(context, message, Toast.LENGTH_SHORT).show() + } + } + + val qrScanner = + rememberLauncherForActivityResult( + QRScanner(), + ) { result -> + if (result.contents == null) { + Toast.makeText(context, "No Data found", Toast.LENGTH_SHORT).show() + } else { + viewModel.import(result.contents) + } + } + + // Track if user wants to start the server (used for permission flow) + var pendingStart by remember { mutableStateOf(false) } + + // Request notification permission for Android 13+ + val notificationPermissionState = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + rememberPermissionState(Manifest.permission.POST_NOTIFICATIONS) { + if (pendingStart) { + pendingStart = false + LocalServerService.startService(context = context, port = 9000) + } + } + } else { + null + } + + Scaffold( + modifier = modifier.fillMaxSize(), + topBar = { + TopAppBar( + title = { + Text(stringResource(R.string.transfer_link_server)) + }, + navigationIcon = { + IconButton(onClick = { + backStack.removeLastOrNull() + }) { + Icon( + TablerIcons.ArrowLeft, + contentDescription = stringResource(R.string.back), + modifier = if (isRtl) Modifier.scale(-1f, 1f) else Modifier, + ) + } + }, + ) + }, + ) { innerPadding -> + Column( + modifier = + Modifier + .fillMaxSize() + .padding(innerPadding) + .verticalScroll(rememberScrollState()) + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + // Server Status Card + Card( + modifier = Modifier.fillMaxWidth(), + colors = + CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainer, + ), + ) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + modifier = Modifier.fillMaxWidth(), + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Icon( + TablerIcons.Server, + contentDescription = null, + tint = + if (isRunning) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onSurface + }, + ) + Text( + text = stringResource(R.string.server_status), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Medium, + ) + } + Switch( + checked = isRunning, + onCheckedChange = { + if (it) { + // Check if notification permission is required and granted + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && + notificationPermissionState?.status?.isGranted == false + ) { + pendingStart = true + notificationPermissionState.launchPermissionRequest() + } else { + LocalServerService.startService( + context = context, + port = 9000, + ) + } + } else { + LocalServerService.stopService(context) + } + }, + ) + } + + Text( + text = + if (isRunning) { + stringResource(R.string.server_running) + } else { + stringResource(R.string.server_stopped) + }, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + + // Server URL Card + AnimatedVisibility( + visible = isRunning && (serverUrl != null), + ) { + Column( + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + Card( + modifier = Modifier.fillMaxWidth(), + colors = + CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainer, + ), + ) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Text( + text = stringResource(R.string.server_url), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Medium, + ) + + Row( + modifier = + Modifier + .fillMaxWidth() + .background( + MaterialTheme.colorScheme.surface, + RoundedCornerShape(8.dp), + ).padding(12.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = serverUrl ?: "", + style = + MaterialTheme.typography.bodyLarge.copy( + fontFamily = FontFamily.Monospace, + ), + modifier = Modifier.weight(1f), + ) + IconButton( + onClick = { + copyToClipboard(context, serverUrl ?: "") + Toast + .makeText( + context, + context.getString(R.string.copied_to_clipboard), + Toast.LENGTH_SHORT, + ).show() + }, + ) { + Icon( + TablerIcons.Copy, + contentDescription = stringResource(R.string.copy), + ) + } + } + } + } + + // QR Code Card + Card( + modifier = Modifier.fillMaxWidth(), + colors = + CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainer, + ), + ) { + Column( + modifier = Modifier.padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Text( + text = stringResource(R.string.scan_qr_code), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Medium, + ) + Text( + text = stringResource(R.string.scan_qr_code_description), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + ) + Spacer(modifier = Modifier.height(8.dp)) + QrCodeView( + data = qrCodeData ?: "", + modifier = Modifier.size(200.dp), + ) + } + } + } + } + + // Instructions Card + Card( + modifier = Modifier.fillMaxWidth(), + colors = + CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainer, + ), + ) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Text( + text = stringResource(R.string.how_to_use), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Medium, + ) + Text( + text = stringResource(R.string.transfer_link_server_instructions), + style = MaterialTheme.typography.bodyMedium, + lineHeight = 20.sp, + ) + } + } + + Card( + modifier = Modifier.fillMaxWidth(), + colors = + CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainer, + ), + ) { + Row( + modifier = + Modifier + .fillMaxWidth() + .padding(12.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = stringResource(R.string.scan_qr_to_get_data), + style = MaterialTheme.typography.bodyLarge, + modifier = Modifier.weight(1f), + ) + IconButton( + onClick = { + qrScanner.launch(ScanOptions()) + }, + ) { + Icon( + TablerIcons.Scan, + contentDescription = stringResource(R.string.qr_scanner), + ) + } + } + } + } + } +} + +private fun copyToClipboard( + context: Context, + text: String, +) { + val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager + val clip = ClipData.newPlainText("Server URL", text) + clipboard.setPrimaryClip(clip) +} diff --git a/app/src/main/java/com/yogeshpaliyal/deepr/ui/screens/home/Home.kt b/app/src/main/java/com/yogeshpaliyal/deepr/ui/screens/home/Home.kt index 2a27fcb..f4cb7ef 100644 --- a/app/src/main/java/com/yogeshpaliyal/deepr/ui/screens/home/Home.kt +++ b/app/src/main/java/com/yogeshpaliyal/deepr/ui/screens/home/Home.kt @@ -139,7 +139,7 @@ fun HomeScreen( var selectedLink by remember { mutableStateOf(null) } val selectedTag by viewModel.selectedTagFilter.collectAsStateWithLifecycle() - val hazeState = rememberHazeState() + val hazeState = rememberHazeState(blurEnabled = true) val context = LocalContext.current val scrollBehavior = SearchBarDefaults.enterAlwaysSearchBarScrollBehavior() val searchBarState = rememberSearchBarState() diff --git a/app/src/main/java/com/yogeshpaliyal/deepr/viewmodel/TransferLinkLocalServerViewModel.kt b/app/src/main/java/com/yogeshpaliyal/deepr/viewmodel/TransferLinkLocalServerViewModel.kt new file mode 100644 index 0000000..ebc8742 --- /dev/null +++ b/app/src/main/java/com/yogeshpaliyal/deepr/viewmodel/TransferLinkLocalServerViewModel.kt @@ -0,0 +1,46 @@ +package com.yogeshpaliyal.deepr.viewmodel + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.yogeshpaliyal.deepr.BuildConfig +import com.yogeshpaliyal.deepr.server.LocalServerRepository +import com.yogeshpaliyal.deepr.server.QRTransferInfo +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.launch +import kotlinx.serialization.json.Json + +class TransferLinkLocalServerViewModel( + private val localServerRepository: LocalServerRepository, +) : ViewModel() { + val isRunning = localServerRepository.isTransferLinkServerRunning + val serverUrl = localServerRepository.transferLinkServerUrl + val qrCodeData = localServerRepository.qrCodeData + + private val transferResultChannel = Channel() + val transferResultFlow = transferResultChannel.receiveAsFlow() + + fun import(data: String) { + viewModelScope.launch { + try { + val qrInfo = Json.decodeFromString(data) + val currentVersion = BuildConfig.VERSION_NAME + if (qrInfo.appVersion != currentVersion) { + transferResultChannel.send("Version mismatch. Sender: ${qrInfo.appVersion}, Receiver: $currentVersion") + return@launch + } + + val result = localServerRepository.fetchAndImportFromSender(qrInfo) + + result + .onSuccess { + transferResultChannel.send("Import Successful") + }.onFailure { error -> + transferResultChannel.send(error.message ?: "Unknown error occurred") + } + } catch (e: Exception) { + transferResultChannel.send("Failed to parse QR code data: ${e.message}") + } + } + } +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 25a6389..99e3770 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -25,7 +25,7 @@ New tag (Optional) No links saved yet Save your link below to quickly access them later. - + More options Copy link @@ -51,7 +51,7 @@ Edit shortcut Show QR Code Link already exists - + Sort by Date Ascending Sort by Date Descending @@ -61,7 +61,7 @@ Sort by Name Descending Sort by Link Ascending Sort by Link Descending - + Edit Tag Delete Tag @@ -133,7 +133,7 @@ No data available to export after mapping. Successfully exported to %s Failed to create CSV file. - + Sync to Local File (Beta) Automatically sync links to a markdown file @@ -148,7 +148,7 @@ Last Sync Never synced %s - + Sync is disabled No sync file selected @@ -157,7 +157,7 @@ No file path provided Invalid markdown table format File validation error: %s - + Auto Backup Automatically backup links to CSV file @@ -176,7 +176,7 @@ No backup location selected Select Interval Select Backup Interval - + Local Network Server Local Server Running @@ -209,8 +209,14 @@ Custom Port Port Number Default: 8080 - Enter port (1024-65535) + Enter port (1024–65535) Invalid port number. Must be between 1024 and 65535. Port changed. Please restart the server to apply changes. Change Port + + + Transfer Link Server + Transfer Link to another Device + 1. Toggle the switch above to start the server\n2. Make sure both devices are on the same Wi-Fi network\n3. Scan the QR code from import links option in another device + Scan to get links from another device \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index b06ba01..fe74229 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -80,6 +80,7 @@ ktor-server-core = { module = "io.ktor:ktor-server-core", version.ref = "ktorCli ktor-server-cio = { module = "io.ktor:ktor-server-cio", version.ref = "ktorClient" } ktor-server-content-negotiation = { module = "io.ktor:ktor-server-content-negotiation", version.ref = "ktorClient" } ktor-serialization-kotlinx-json = { module = "io.ktor:ktor-serialization-kotlinx-json", version.ref = "ktorClient" } +ktor-client-content-negotiation = { module = "io.ktor:ktor-client-content-negotiation", version.ref = "ktorClient" } coil-compose = { module = "io.coil-kt.coil3:coil-compose", version.ref = "coilCompose" } coil-network-ktor3 = { module = "io.coil-kt.coil3:coil-network-ktor3", version.ref = "coilCompose" } firebase-crashlytics-ndk = { module = "com.google.firebase:firebase-crashlytics-ndk" }