From fcb3746e31bc6024598ade5090e5d06a35a57246 Mon Sep 17 00:00:00 2001 From: Mutant-Slayer Date: Wed, 29 Oct 2025 09:12:51 +0530 Subject: [PATCH 1/3] [Feature] Transfer Link between multiple device (#234) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * bug fix for url copy button not visible * transfer server wip * scan to get data flow wip * conflict resolved * task done and dusted 🎉🥳 * enabled haze for all android versions * ui and duplication issue fixed * ui issues fixed * fixed as per comments * changes done as per review, api call flow broken need to fix * conflict resolve --------- Co-authored-by: anas shikoh --- app/build.gradle.kts | 1 + app/src/main/AndroidManifest.xml | 9 +- .../yogeshpaliyal/deepr/DeeprApplication.kt | 23 +- .../com/yogeshpaliyal/deepr/MainActivity.kt | 7 + .../deepr/server/LocalServerRepository.kt | 7 +- .../deepr/server/LocalServerRepositoryImpl.kt | 221 +++++++++- .../deepr/server/LocalServerService.kt | 21 +- .../deepr/ui/screens/LocalNetworkServer.kt | 24 +- .../deepr/ui/screens/Settings.kt | 13 +- .../ui/screens/TransferLinkLocalServer.kt | 387 ++++++++++++++++++ .../deepr/ui/screens/home/Home.kt | 2 +- .../TransferLinkLocalServerViewModel.kt | 46 +++ app/src/main/res/values/strings.xml | 22 +- gradle/libs.versions.toml | 1 + 14 files changed, 735 insertions(+), 49 deletions(-) create mode 100644 app/src/main/java/com/yogeshpaliyal/deepr/ui/screens/TransferLinkLocalServer.kt create mode 100644 app/src/main/java/com/yogeshpaliyal/deepr/viewmodel/TransferLinkLocalServerViewModel.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 9417c9d0..46c45cfd 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 72086a35..ecf14c69 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 c7491ed2..860ccc62 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 43a510b0..2f8ec106 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 dd41ba75..0e4d40a2 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 bc001c7e..fac4e303 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 6a5c6eef..9d67685f 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 79f95eb8..d6578764 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 86572012..d25b93f6 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 00000000..f8be78e5 --- /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 2a27fcbe..f4cb7efa 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 00000000..ebc87428 --- /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 25a63897..99e3770d 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 b06ba015..fe742299 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" } From e9d07a746243a5826ae278571b83709b5dfebc50 Mon Sep 17 00:00:00 2001 From: Yogesh Choudhary Paliyal Date: Wed, 29 Oct 2025 09:50:17 +0530 Subject: [PATCH 2/3] Refactor LocalServerRepository to remove transfer link server functionality --- .../deepr/server/LocalServerRepository.kt | 2 - .../deepr/server/LocalServerRepositoryImpl.kt | 41 +++++-------------- .../deepr/server/LocalServerService.kt | 10 ----- .../TransferLinkLocalServerViewModel.kt | 4 +- 4 files changed, 13 insertions(+), 44 deletions(-) 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 0e4d40a2..3843509b 100644 --- a/app/src/main/java/com/yogeshpaliyal/deepr/server/LocalServerRepository.kt +++ b/app/src/main/java/com/yogeshpaliyal/deepr/server/LocalServerRepository.kt @@ -6,8 +6,6 @@ interface LocalServerRepository { val isRunning: StateFlow val serverUrl: StateFlow val serverPort: StateFlow - val isTransferLinkServerRunning: StateFlow - val transferLinkServerUrl: StateFlow val qrCodeData: StateFlow suspend fun startServer(port: Int) 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 fac4e303..f9895a3e 100644 --- a/app/src/main/java/com/yogeshpaliyal/deepr/server/LocalServerRepositoryImpl.kt +++ b/app/src/main/java/com/yogeshpaliyal/deepr/server/LocalServerRepositoryImpl.kt @@ -67,13 +67,6 @@ class LocalServerRepositoryImpl( 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 { @@ -96,10 +89,7 @@ class LocalServerRepositoryImpl( } override suspend fun startServer(port: Int) { - if (isRunning.value || isTransferLinkServerRunning.value) { - if (port == 9000) { - generateQRCode(port)?.let { qrData -> _qrCodeData.update { qrData } } - } + if (isRunning.value) { Log.d("LocalServer", "Server is already running") return } @@ -111,7 +101,7 @@ class LocalServerRepositoryImpl( return } - val port = _serverPort.value + val port = port server = embeddedServer(CIO, host = "0.0.0.0", port = port) { @@ -296,26 +286,19 @@ class LocalServerRepositoryImpl( } server?.start(wait = false) + + _isRunning.update { true } + _serverUrl.update { "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}") + generateQRCode(port)?.let { qrData -> _qrCodeData.update { qrData } } } } catch (e: Exception) { Log.e("LocalServer", "Error starting server", e) - if (port == 9000) { - _isTransferLinkServerRunning.update { false } - _transferLinkServerUrl.update { null } - } else { - _isRunning.update { false } - _serverUrl.update { null } - } + + _isRunning.update { false } + _serverUrl.update { null } } } @@ -325,8 +308,6 @@ class LocalServerRepositoryImpl( server = 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) 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 9d67685f..7aee76cf 100644 --- a/app/src/main/java/com/yogeshpaliyal/deepr/server/LocalServerService.kt +++ b/app/src/main/java/com/yogeshpaliyal/deepr/server/LocalServerService.kt @@ -69,16 +69,6 @@ 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() { diff --git a/app/src/main/java/com/yogeshpaliyal/deepr/viewmodel/TransferLinkLocalServerViewModel.kt b/app/src/main/java/com/yogeshpaliyal/deepr/viewmodel/TransferLinkLocalServerViewModel.kt index ebc87428..ffa88a62 100644 --- a/app/src/main/java/com/yogeshpaliyal/deepr/viewmodel/TransferLinkLocalServerViewModel.kt +++ b/app/src/main/java/com/yogeshpaliyal/deepr/viewmodel/TransferLinkLocalServerViewModel.kt @@ -13,8 +13,8 @@ import kotlinx.serialization.json.Json class TransferLinkLocalServerViewModel( private val localServerRepository: LocalServerRepository, ) : ViewModel() { - val isRunning = localServerRepository.isTransferLinkServerRunning - val serverUrl = localServerRepository.transferLinkServerUrl + val isRunning = localServerRepository.isRunning + val serverUrl = localServerRepository.serverUrl val qrCodeData = localServerRepository.qrCodeData private val transferResultChannel = Channel() From 29e3eaf5f8e51b63109c2bf3569593d75fd76e15 Mon Sep 17 00:00:00 2001 From: Yogesh Choudhary Paliyal Date: Thu, 30 Oct 2025 19:25:17 +0530 Subject: [PATCH 3/3] Add LocalServerTransferLink class and update server functionality --- .../yogeshpaliyal/deepr/DeeprApplication.kt | 5 ++ .../deepr/server/LocalServerRepository.kt | 2 +- .../deepr/server/LocalServerRepositoryImpl.kt | 66 +++++++++---------- .../deepr/server/LocalServerTransferLink.kt | 28 ++++++++ .../ui/screens/TransferLinkLocalServer.kt | 55 ++++++++-------- app/src/main/res/values/strings.xml | 2 +- 6 files changed, 97 insertions(+), 61 deletions(-) create mode 100644 app/src/main/java/com/yogeshpaliyal/deepr/server/LocalServerTransferLink.kt diff --git a/app/src/main/java/com/yogeshpaliyal/deepr/DeeprApplication.kt b/app/src/main/java/com/yogeshpaliyal/deepr/DeeprApplication.kt index 860ccc62..5d10f405 100644 --- a/app/src/main/java/com/yogeshpaliyal/deepr/DeeprApplication.kt +++ b/app/src/main/java/com/yogeshpaliyal/deepr/DeeprApplication.kt @@ -17,6 +17,7 @@ import com.yogeshpaliyal.deepr.review.ReviewManager import com.yogeshpaliyal.deepr.review.ReviewManagerFactory import com.yogeshpaliyal.deepr.server.LocalServerRepository import com.yogeshpaliyal.deepr.server.LocalServerRepositoryImpl +import com.yogeshpaliyal.deepr.server.LocalServerTransferLink import com.yogeshpaliyal.deepr.sync.SyncRepository import com.yogeshpaliyal.deepr.sync.SyncRepositoryImpl import com.yogeshpaliyal.deepr.viewmodel.AccountViewModel @@ -102,6 +103,10 @@ class DeeprApplication : Application() { LocalServerRepositoryImpl(androidContext(), get(), get(), get(), get(), get()) } + factory { + LocalServerTransferLink(androidContext(), get(), get(), get(), get(), get()) + } + viewModel { LocalServerViewModel(get()) } 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 3843509b..b9192d99 100644 --- a/app/src/main/java/com/yogeshpaliyal/deepr/server/LocalServerRepository.kt +++ b/app/src/main/java/com/yogeshpaliyal/deepr/server/LocalServerRepository.kt @@ -10,7 +10,7 @@ interface LocalServerRepository { suspend fun startServer(port: Int) - suspend fun stopServer() + fun stopServer() suspend fun setServerPort(port: Int) 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 f9895a3e..29696517 100644 --- a/app/src/main/java/com/yogeshpaliyal/deepr/server/LocalServerRepositoryImpl.kt +++ b/app/src/main/java/com/yogeshpaliyal/deepr/server/LocalServerRepositoryImpl.kt @@ -45,7 +45,7 @@ import kotlinx.serialization.json.Json import java.net.NetworkInterface import java.util.Locale -class LocalServerRepositoryImpl( +open class LocalServerRepositoryImpl( private val context: Context, private val deeprQueries: DeeprQueries, private val httpClient: HttpClient, @@ -167,6 +167,7 @@ class LocalServerRepositoryImpl( createdAt = link.createdAt, openedCount = link.openedCount, notes = link.notes, + thumbnail = link.thumbnail, tags = link.tagsNames ?.split(", ") @@ -207,6 +208,30 @@ class LocalServerRepositoryImpl( } } + post("/api/links") { + try { + val request = call.receive() + // Insert the link without tags first + accountViewModel.insertAccount( + request.link, + request.name, + false, + request.tags.map { it.toDbTag() }, + request.notes, + ) + 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}"), + ) + } + } + get("/api/tags") { try { // Get all tags from the database with their IDs @@ -302,7 +327,7 @@ class LocalServerRepositoryImpl( } } - override suspend fun stopServer() { + override fun stopServer() { try { server?.stop(1000, 2000) server = null @@ -323,22 +348,20 @@ class LocalServerRepositoryImpl( protocol = URLProtocol.HTTP host = qrTransferInfo.ip port = qrTransferInfo.port - path("api/export") + path("api/links") } 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() + val exportedData: List = response.body() importToDatabase(exportedData) @@ -349,9 +372,9 @@ class LocalServerRepositoryImpl( } } - private fun importToDatabase(data: ExportedData) { + private fun importToDatabase(links: List) { deeprQueries.transaction { - data.links.forEach { deeplink -> + links.forEach { deeplink -> if (deeprQueries.getDeeprByLink(deeplink.link).executeAsList().isEmpty()) { deeprQueries.insertDeepr( link = deeplink.link, @@ -365,9 +388,7 @@ class LocalServerRepositoryImpl( deeplink.tags.forEach { tagName -> deeprQueries.insertTag(name = tagName) - val tag = deeprQueries.getTagByName(tagName).executeAsOne() - deeprQueries.addTagToLink( linkId = insertedId, tagId = tag.id, @@ -382,10 +403,6 @@ class LocalServerRepositoryImpl( } } } - - data.tags.forEach { tagName -> - deeprQueries.insertTag(name = tagName) - } } } @@ -452,6 +469,8 @@ data class LinkResponse( val createdAt: String, val openedCount: Long, val notes: String, + val thumbnail: String, + val isFavourite: Boolean = false, val tags: List, ) @@ -500,22 +519,3 @@ data class QRTransferInfo( 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/LocalServerTransferLink.kt b/app/src/main/java/com/yogeshpaliyal/deepr/server/LocalServerTransferLink.kt new file mode 100644 index 00000000..4b4a6bff --- /dev/null +++ b/app/src/main/java/com/yogeshpaliyal/deepr/server/LocalServerTransferLink.kt @@ -0,0 +1,28 @@ +package com.yogeshpaliyal.deepr.server + +import android.content.Context +import com.yogeshpaliyal.deepr.DeeprQueries +import com.yogeshpaliyal.deepr.data.NetworkRepository +import com.yogeshpaliyal.deepr.preference.AppPreferenceDataStore +import com.yogeshpaliyal.deepr.viewmodel.AccountViewModel +import io.ktor.client.HttpClient + +class LocalServerTransferLink( + context: Context, + deeprQueries: DeeprQueries, + httpClient: HttpClient, + accountViewModel: AccountViewModel, + networkRepository: NetworkRepository, + preferenceDataStore: AppPreferenceDataStore, +) : LocalServerRepositoryImpl( + context, + deeprQueries, + httpClient, + accountViewModel, + networkRepository, + preferenceDataStore, + ) { + override suspend fun startServer(port: Int) { + super.startServer(port) + } +} 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 index f8be78e5..16185b7b 100644 --- a/app/src/main/java/com/yogeshpaliyal/deepr/ui/screens/TransferLinkLocalServer.kt +++ b/app/src/main/java/com/yogeshpaliyal/deepr/ui/screens/TransferLinkLocalServer.kt @@ -32,10 +32,12 @@ import androidx.compose.material3.Switch import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.runtime.snapshots.SnapshotStateList import androidx.compose.ui.Alignment @@ -52,12 +54,12 @@ 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.server.LocalServerTransferLink import com.yogeshpaliyal.deepr.util.QRScanner import com.yogeshpaliyal.deepr.viewmodel.TransferLinkLocalServerViewModel import compose.icons.TablerIcons @@ -66,7 +68,9 @@ import compose.icons.tablericons.Copy import compose.icons.tablericons.Scan import compose.icons.tablericons.Server import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.launch import org.koin.androidx.compose.koinViewModel +import org.koin.compose.koinInject data object TransferLinkLocalNetworkServer @@ -78,10 +82,18 @@ fun TransferLinkLocalServerScreen( 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 localServerInstance = koinInject() + val isRunning by localServerInstance.isRunning.collectAsStateWithLifecycle() + val serverUrl by localServerInstance.serverUrl.collectAsStateWithLifecycle() + val qrCodeData by localServerInstance.qrCodeData.collectAsStateWithLifecycle() val isRtl = LocalLayoutDirection.current == LayoutDirection.Rtl + val coroutine = rememberCoroutineScope() + + DisposableEffect(Unit) { + onDispose { + localServerInstance.stopServer() + } + } LaunchedEffect(true) { viewModel.transferResultFlow.collectLatest { message -> @@ -104,17 +116,16 @@ fun TransferLinkLocalServerScreen( 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) - } + 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 } + } else { + null + } Scaffold( modifier = modifier.fillMaxSize(), @@ -186,21 +197,13 @@ fun TransferLinkLocalServerScreen( 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() + coroutine.launch { + if (it) { + // Check if notification permission is required and granted + localServerInstance.startServer(9000) } else { - LocalServerService.startService( - context = context, - port = 9000, - ) + localServerInstance.stopServer() } - } else { - LocalServerService.stopService(context) } }, ) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 99e3770d..90c85181 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -216,7 +216,7 @@ Transfer Link Server - Transfer Link to another Device + Transfer Link to another Device (Beta) 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