Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
198 changes: 198 additions & 0 deletions app/src/main/java/io/github/chrisimx/scanbridge/PrinterBrowser.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,198 @@
/*
* Copyright (C) 2024-2025 Christian Nagel and contributors
*
* This file is part of ScanBridge.
*
* ScanBridge is free software: you can redistribute it and/or modify it under the terms of
* the GNU General Public License as published by the Free Software Foundation, either
* version 3 of the License, or (at your option) any later version.
*
* ScanBridge is distributed in the hope that it will be useful, but WITHOUT ANY
* WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
* FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License along with eSCLKt.
* If not, see <https://www.gnu.org/licenses/>.
*
* SPDX-License-Identifier: GPL-3.0-or-later
*/

package io.github.chrisimx.scanbridge

import android.content.Context
import android.net.nsd.NsdManager
import androidx.compose.animation.AnimatedContent
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.snapshots.SnapshotStateMap
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavController
import io.github.chrisimx.scanbridge.data.ui.CustomPrinterViewModel
import io.github.chrisimx.scanbridge.uicomponents.FoundPrinterItem
import io.github.chrisimx.scanbridge.uicomponents.FullScreenError
import io.github.chrisimx.scanbridge.uicomponents.dialog.CustomPrinterDialog
import timber.log.Timber
import java.util.*
import android.app.Application
import kotlin.uuid.ExperimentalUuidApi
import kotlin.uuid.Uuid
import okhttp3.HttpUrl.Companion.toHttpUrl
import io.github.chrisimx.scanbridge.data.model.CustomPrinter

@OptIn(ExperimentalUuidApi::class)
@Composable
fun PrinterBrowser(
innerPadding: PaddingValues,
navController: NavController,
showCustomDialog: Boolean,
setShowCustomDialog: (Boolean) -> Unit,
statefulPrinterMap: SnapshotStateMap<String, DiscoveredPrinter>
) {
val customPrinterViewModel: CustomPrinterViewModel = viewModel(
factory = ViewModelProvider.AndroidViewModelFactory(LocalContext.current.applicationContext as Application)
)

AnimatedContent(
targetState = statefulPrinterMap.isNotEmpty() || customPrinterViewModel.customPrinters.isNotEmpty(),
label = "PrinterList"
) {
if (it) {
PrinterList(innerPadding, navController, statefulPrinterMap, customPrinterViewModel)
} else {
FullScreenError(
R.drawable.twotone_wifi_find_24,
stringResource(R.string.no_printers_found)
)
}
}

if (showCustomDialog) {
val context = LocalContext.current
CustomPrinterDialog(
onDismiss = { setShowCustomDialog(false) },
onConnectClicked = { name, url, save ->
val printerName = if (name.isEmpty()) context.getString(R.string.custom_printer) else name
val sessionID = Uuid.random().toString()
if (save) {
customPrinterViewModel.addPrinter(CustomPrinter(Uuid.random(), printerName, url))
}
setShowCustomDialog(false)
navController.navigate(
PrinterRoute(
printerName,
url.toString(),
sessionID
)
)
}
)
}
}

fun startPrinterDiscovery(
context: Context,
printerMap: SnapshotStateMap<String, DiscoveredPrinter>
): Optional<Pair<NsdManager, Array<PrinterDiscovery>>> {
val service = context.getSystemService(Context.NSD_SERVICE) as? NsdManager
if (service == null) {
Timber.e("Couldn't get NsdManager service")
return Optional.empty()
}
val listener = PrinterDiscovery(service, isSecure = false, printerMap)
val listenerSecure = PrinterDiscovery(service, isSecure = true, printerMap)
service.discoverServices("_ipp._tcp", NsdManager.PROTOCOL_DNS_SD, listener)
service.discoverServices("_ipps._tcp", NsdManager.PROTOCOL_DNS_SD, listenerSecure)
Timber.i("Printer discovery started")
return Optional.of(Pair(service, arrayOf(listener, listenerSecure)))
}

@Composable
fun PrinterList(
innerPadding: PaddingValues,
navController: NavController,
statefulPrinterMap: SnapshotStateMap<String, DiscoveredPrinter>,
customPrinterViewModel: CustomPrinterViewModel
) {
LazyColumn(
modifier = Modifier
.padding(innerPadding)
.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
reverseLayout = true
) {
statefulPrinterMap.forEach {
val discoveredPrinter = it.value
discoveredPrinter.addresses.forEach { address ->
item {
FoundPrinterItem(discoveredPrinter.name, address, navController)
}
}
}

if (customPrinterViewModel.customPrinters.isNotEmpty() && statefulPrinterMap.isNotEmpty()) {
item {
Text(
stringResource(R.string.discovered_printers),
modifier = Modifier.fillMaxWidth(1f).padding(start = 16.dp),
style = MaterialTheme.typography.labelMedium
)
}

item {
HorizontalDivider(modifier = Modifier.padding(horizontal = 10.dp, vertical = 10.dp))
}
}

customPrinterViewModel.customPrinters.forEach { customPrinter ->
item {
FoundPrinterItem(
customPrinter.name,
customPrinter.url.toString(),
navController,
{
customPrinterViewModel.deletePrinter(customPrinter)
}
)
}
}

if (customPrinterViewModel.customPrinters.isNotEmpty() && statefulPrinterMap.isNotEmpty()) {
item {
Text(
stringResource(R.string.saved_printers),
modifier = Modifier.fillMaxWidth(1f).padding(start = 16.dp),
style = MaterialTheme.typography.labelMedium
)
}
}

if (statefulPrinterMap.isEmpty() && customPrinterViewModel.customPrinters.isEmpty()) {
item {
FullScreenError(
R.drawable.twotone_wifi_find_24,
stringResource(R.string.no_printers_found)
)
}
}
}
}

@Composable
fun FoundPrinterItem(name: String, address: String, navController: NavController) {
io.github.chrisimx.scanbridge.uicomponents.FoundPrinterItem(name, address, navController)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
/*
* Copyright (C) 2024-2025 Christian Nagel and contributors
*
* This file is part of ScanBridge.
*
* ScanBridge is free software: you can redistribute it and/or modify it under the terms of
* the GNU General Public License as published by the Free Software Foundation, either
* version 3 of the License, or (at your option) any later version.
*
* ScanBridge is distributed in the hope that it will be useful, but WITHOUT ANY
* WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
* FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License along with eSCLKt.
* If not, see <https://www.gnu.org/licenses/>.
*
* SPDX-License-Identifier: GPL-3.0-or-later
*/

package io.github.chrisimx.scanbridge

import android.net.nsd.NsdManager
import android.net.nsd.NsdServiceInfo
import android.os.Build
import android.os.ext.SdkExtensions
import androidx.compose.runtime.snapshots.SnapshotStateMap
import java.nio.charset.StandardCharsets
import java.util.concurrent.ForkJoinPool
import okhttp3.HttpUrl
import timber.log.Timber

private const val TAG = "PrinterDiscovery"

data class DiscoveredPrinter(val name: String, val addresses: List<String>)

class PrinterDiscovery(
val nsdManager: NsdManager,
val isSecure: Boolean,
val statefulPrinterMap: SnapshotStateMap<String, DiscoveredPrinter>
) : NsdManager.DiscoveryListener {

override fun onDiscoveryStarted(regType: String) {
Timber.i("Printer discovery started")
}

override fun onServiceFound(service: NsdServiceInfo) {
Timber
.d(
"Service (${service.hashCode()}) discovery success ${if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) service.hostAddresses else service.host} ${service.serviceType} ${service.serviceName} ${service.port}"
)

val serviceIdentifier = "${service.serviceName}.${service.serviceType}"
if (statefulPrinterMap.contains(serviceIdentifier)) {
Timber.d("Ignored service. Got it already")
return
}
if (!isSecure && service.serviceType != "_ipp._tcp.") {
return
}
if (isSecure && service.serviceType != "_ipps._tcp.") {
return
}

if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R && SdkExtensions.getExtensionVersion(Build.VERSION_CODES.TIRAMISU) >= 7) {
val serviceInfoCallback =
object : NsdManager.ServiceInfoCallback {

override fun onServiceInfoCallbackRegistrationFailed(p0: Int) {
Timber.tag(TAG).d("ServiceInfoCallBack (${this.hashCode()}) Registration failed!!! $p0")
}

override fun onServiceUpdated(p0: NsdServiceInfo) {
Timber.tag(TAG).d("Service (${this.hashCode()}) updated! $p0")
var rp = p0.attributes["rp"]?.toString(StandardCharsets.UTF_8) ?: "/ipp/print"

rp = if (rp.isEmpty()) "/ipp/print" else "/$rp/"

val urls = mutableListOf<String>()

val addresses = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
p0.hostAddresses
} else {
listOf(p0.host)
}

for (address in addresses) {
if (address.isLinkLocalAddress) {
Timber.tag(TAG).d("Ignoring link local address: ${address.hostAddress}")
continue
}
val sanitizedURL = address.hostAddress!!.substringBefore('%')
val url = try {
HttpUrl.Builder()
.host(sanitizedURL)
.port(p0.port)
.scheme(if (isSecure) "ipps" else "ipp")
.addPathSegment(rp.trim('/'))
.build()
.toString()
} catch (e: Exception) {
Timber.tag(TAG).d("Failed to build URL: $e")
continue
}

urls.add(url)
}

if (urls.isNotEmpty()) {
statefulPrinterMap[serviceIdentifier] = DiscoveredPrinter(p0.serviceName, urls)
}
}

override fun onServiceLost() {
Timber.tag(TAG).d("Service (${this.hashCode()}) lost!")
statefulPrinterMap.remove(serviceIdentifier)
}

override fun onServiceInfoCallbackUnregistered() {
Timber.tag(TAG).d("ServiceInfoCallBack (${this.hashCode()}) unregistered!")
}
}

Timber.tag(TAG).d("Registering ServiceInfoCallback (${serviceInfoCallback.hashCode()}) for $serviceIdentifier")
nsdManager.registerServiceInfoCallback(service, ForkJoinPool.commonPool(), serviceInfoCallback)
} else {
nsdManager.resolveService(service, object : NsdManager.ResolveListener {
override fun onResolveFailed(serviceInfo: NsdServiceInfo, errorCode: Int) {
Timber.e("Resolve failed: $errorCode")
}

override fun onServiceResolved(serviceInfo: NsdServiceInfo) {
Timber.d("Resolve Succeeded. $serviceInfo")

val sanitizedURL = serviceInfo.host.hostAddress!!.substringBefore('%')
val url = try {
HttpUrl.Builder()
.host(sanitizedURL)
.port(serviceInfo.port)
.scheme(if (isSecure) "ipps" else "ipp")
.addPathSegment("ipp")
.addPathSegment("print")
.build()
.toString()
} catch (e: Exception) {
Timber.tag(TAG).d("Failed to build URL: $e")
return
}

statefulPrinterMap[serviceIdentifier] = DiscoveredPrinter(serviceInfo.serviceName, listOf(url))
}
})
}
}

override fun onServiceLost(service: NsdServiceInfo) {
Timber.e("service lost: $service")
val serviceIdentifier = "${service.serviceName}.${service.serviceType}"
statefulPrinterMap.remove(serviceIdentifier)
}

override fun onDiscoveryStopped(serviceType: String) {
Timber.i("Discovery stopped: $serviceType")
}

override fun onStartDiscoveryFailed(serviceType: String, errorCode: Int) {
Timber.e("Discovery failed: Error code: $errorCode")
nsdManager.stopServiceDiscovery(this)
}

override fun onStopDiscoveryFailed(serviceType: String, errorCode: Int) {
Timber.e("Discovery failed: Error code: $errorCode")
nsdManager.stopServiceDiscovery(this)
}
}
Loading