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" }