diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 3ff59515ed7..213f56c79d2 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -73,6 +73,9 @@ + + + + + + + + + + diff --git a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/local/LocalDeviceDiscovery.kt b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/local/LocalDeviceDiscovery.kt new file mode 100644 index 00000000000..15fdaefa76c --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/local/LocalDeviceDiscovery.kt @@ -0,0 +1,61 @@ +package com.lagradost.cloudstream3.syncproviders.local + +import android.content.Context +import android.net.nsd.NsdManager +import android.net.nsd.NsdServiceInfo +import android.net.wifi.WifiManager +import android.os.Build +import java.io.Closeable +import java.net.InetAddress + +internal class LocalDeviceDiscovery(context: Context, private val found: (String, InetAddress, Int) -> Unit) : Closeable { + companion object { const val TYPE = "_cs3handoff._tcp." } + private val manager = context.getSystemService(Context.NSD_SERVICE) as NsdManager + @Volatile private var active = true + private var registration: NsdManager.RegistrationListener? = null + private var discovery: NsdManager.DiscoveryListener? = null + private val multicast = (context.applicationContext.getSystemService(Context.WIFI_SERVICE) as? WifiManager) + ?.createMulticastLock("cloudstream-manual-share")?.apply { setReferenceCounted(false); acquire() } + + fun advertise(port: Int) { + val listener = object : NsdManager.RegistrationListener { + override fun onServiceRegistered(info: NsdServiceInfo) { if (!active) runCatching { manager.unregisterService(this) } } + override fun onRegistrationFailed(info: NsdServiceInfo, error: Int) {} + override fun onServiceUnregistered(info: NsdServiceInfo) {} + override fun onUnregistrationFailed(info: NsdServiceInfo, error: Int) {} + } + registration = listener + manager.registerService(NsdServiceInfo().apply { + serviceName = "CloudStream-${Build.MODEL.take(24)}"; serviceType = TYPE; this.port = port + }, NsdManager.PROTOCOL_DNS_SD, listener) + } + + fun discover() { + val listener = object : NsdManager.DiscoveryListener { + override fun onDiscoveryStarted(type: String) { if (!active) runCatching { manager.stopServiceDiscovery(this) } } + override fun onDiscoveryStopped(type: String) {} + override fun onStartDiscoveryFailed(type: String, error: Int) {} + override fun onStopDiscoveryFailed(type: String, error: Int) {} + override fun onServiceLost(info: NsdServiceInfo) {} + override fun onServiceFound(info: NsdServiceInfo) { + if (!active || info.serviceType.trimEnd('.') != TYPE.trimEnd('.')) return + manager.resolveService(info, object : NsdManager.ResolveListener { + override fun onResolveFailed(service: NsdServiceInfo, error: Int) {} + override fun onServiceResolved(service: NsdServiceInfo) { + val address = service.host ?: return + if (active && LocalPairingTransport.isLocal(address)) found(service.serviceName, address, service.port) + } + }) + } + } + discovery = listener + manager.discoverServices(TYPE, NsdManager.PROTOCOL_DNS_SD, listener) + } + + override fun close() { + active = false + registration?.let { runCatching { manager.unregisterService(it) } }; registration = null + discovery?.let { runCatching { manager.stopServiceDiscovery(it) } }; discovery = null + multicast?.let { if (it.isHeld) it.release() } + } +} diff --git a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/local/LocalPairingTransport.kt b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/local/LocalPairingTransport.kt new file mode 100644 index 00000000000..d8a1cf1b671 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/local/LocalPairingTransport.kt @@ -0,0 +1,121 @@ +package com.lagradost.cloudstream3.syncproviders.local + +import java.io.Closeable +import java.io.DataInputStream +import java.io.DataOutputStream +import java.io.IOException +import java.net.InetAddress +import java.net.InetSocketAddress +import java.net.ServerSocket +import java.net.Socket +import java.net.SocketTimeoutException +import java.security.KeyFactory +import java.security.KeyPair +import java.security.KeyPairGenerator +import java.security.MessageDigest +import java.security.SecureRandom +import java.security.spec.ECGenParameterSpec +import java.security.spec.X509EncodedKeySpec +import java.util.concurrent.atomic.AtomicBoolean +import java.util.concurrent.atomic.AtomicReference +import javax.crypto.Cipher +import javax.crypto.KeyAgreement +import javax.crypto.spec.GCMParameterSpec +import javax.crypto.spec.SecretKeySpec + +internal object LocalPairingTransport { + private const val MAGIC = 0x43533331 + private const val MAX_PAYLOAD = 65_536 + private val random = SecureRandom() + private const val CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789" + private fun keys(): KeyPair = KeyPairGenerator.getInstance("EC").apply { + initialize(ECGenParameterSpec("secp256r1"), random) + }.generateKeyPair() + + private fun key(own: KeyPair, remote: ByteArray, code: String, salt: ByteArray, receiver: ByteArray, sender: ByteArray): SecretKeySpec { + val agreement = KeyAgreement.getInstance("ECDH") + agreement.init(own.private) + agreement.doPhase(KeyFactory.getInstance("EC").generatePublic(X509EncodedKeySpec(remote)), true) + val digest = MessageDigest.getInstance("SHA-256") + digest.update(agreement.generateSecret()); digest.update(code.toByteArray(Charsets.UTF_8)) + digest.update(salt); digest.update(receiver); digest.update(sender) + return SecretKeySpec(digest.digest(), "AES") + } + + private fun DataInputStream.frame(max: Int): ByteArray { + val length = readInt() + if (length !in 1..max) throw IOException("Invalid local transfer frame") + return ByteArray(length).also { readFully(it) } + } + private fun DataOutputStream.frame(bytes: ByteArray) { writeInt(bytes.size); write(bytes); flush() } + fun isLocal(address: InetAddress) = address.isSiteLocalAddress || address.isLoopbackAddress || address.isLinkLocalAddress || + (address.address.size == 16 && address.address[0].toInt().and(0xfe) == 0xfc) + + class Receiver(private val receive: (ByteArray) -> Boolean, private val ended: () -> Unit = {}) : Closeable { + private val socket = ServerSocket(0).apply { soTimeout = 1000 } + private val pair = keys() + private val salt = ByteArray(32).also(random::nextBytes) + private val open = AtomicBoolean(true) + private val activePeer = AtomicReference(null) + val port: Int get() = socket.localPort + val code = (1..12).map { CODE_ALPHABET[random.nextInt(CODE_ALPHABET.length)] }.joinToString("") + private val deadline = System.nanoTime() + 120_000_000_000L + fun start() { + Thread({ + var attempts = 0 + try { + while (open.get() && attempts < 5 && System.nanoTime() < deadline) { + val peer = try { socket.accept() } catch (_: SocketTimeoutException) { continue } + activePeer.set(peer) + peer.use { + if (!isLocal(peer.inetAddress)) return@use + attempts++ + peer.soTimeout = 4000 + try { + val input = DataInputStream(peer.getInputStream()) + val output = DataOutputStream(peer.getOutputStream()) + output.writeInt(MAGIC); output.frame(pair.public.encoded); output.frame(salt) + val sender = input.frame(256) + val iv = input.frame(12) + if (iv.size != 12) throw IOException("Invalid local transfer nonce") + val encrypted = input.frame(MAX_PAYLOAD + 16) + val cipher = Cipher.getInstance("AES/GCM/NoPadding") + cipher.init(Cipher.DECRYPT_MODE, key(pair, sender, code, salt, pair.public.encoded, sender), GCMParameterSpec(128, iv)) + val plain = cipher.doFinal(encrypted) + val accepted = plain.size <= MAX_PAYLOAD && receive(plain) + output.writeBoolean(accepted); output.flush() + if (accepted) close() + } catch (_: Exception) { } + } + } + } catch (_: IOException) { } + finally { close(); ended() } + }, "cloudstream-local-receive").apply { isDaemon = true }.start() + } + override fun close() { + if (open.getAndSet(false)) socket.close() + activePeer.getAndSet(null)?.let { runCatching { it.close() } } + } + } + + fun send(address: InetAddress, port: Int, code: String, payload: ByteArray): Boolean { + val normalizedCode = code.uppercase(java.util.Locale.ROOT).filterNot { it.isWhitespace() || it == '-' } + require(isLocal(address)) { "Only local network addresses are supported" } + require(port in 1..65535 && normalizedCode.length == 12 && normalizedCode.all { it in CODE_ALPHABET } && payload.size in 1..MAX_PAYLOAD) + Socket().use { socket -> + socket.connect(InetSocketAddress(address, port), 4000); socket.soTimeout = 5000 + val input = DataInputStream(socket.getInputStream()) + val output = DataOutputStream(socket.getOutputStream()) + if (input.readInt() != MAGIC) throw IOException("Unsupported local transfer version") + val receiver = input.frame(256) + val salt = input.frame(32) + require(salt.size == 32) + val pair = keys() + val iv = ByteArray(12).also(random::nextBytes) + val cipher = Cipher.getInstance("AES/GCM/NoPadding") + cipher.init(Cipher.ENCRYPT_MODE, key(pair, receiver, normalizedCode, salt, receiver, pair.public.encoded), GCMParameterSpec(128, iv)) + output.frame(pair.public.encoded); output.frame(iv); output.frame(cipher.doFinal(payload)) + return input.readBoolean() + } + } +} diff --git a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/local/LocalShareActivity.kt b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/local/LocalShareActivity.kt new file mode 100644 index 00000000000..afdf9aac526 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/local/LocalShareActivity.kt @@ -0,0 +1,209 @@ +package com.lagradost.cloudstream3.syncproviders.local + +import android.content.Intent +import android.net.Uri +import android.os.Build +import android.os.Bundle +import android.text.InputType +import android.util.Base64 +import android.view.View +import android.widget.ArrayAdapter +import android.widget.Button +import android.widget.EditText +import android.widget.LinearLayout +import android.widget.ListView +import android.widget.ScrollView +import android.widget.TextView +import androidx.activity.result.contract.ActivityResultContracts +import androidx.appcompat.app.AlertDialog +import androidx.appcompat.app.AppCompatActivity +import androidx.core.content.ContextCompat +import androidx.lifecycle.lifecycleScope +import com.lagradost.cloudstream3.MainActivity +import com.lagradost.cloudstream3.R +import com.lagradost.cloudstream3.ui.home.HomeViewModel +import com.lagradost.cloudstream3.utils.AppUtils.toJson +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import java.net.InetAddress +import java.net.NetworkInterface +import java.util.concurrent.atomic.AtomicBoolean + +class LocalShareActivity : AppCompatActivity() { + private val active = AtomicBoolean(false) + private var receiver: LocalPairingTransport.Receiver? = null + private var network: LocalDeviceDiscovery? = null + private lateinit var status: TextView + private var permissionAction: (() -> Unit)? = null + @Volatile private var generation = 0L + private val permission = registerForActivityResult(ActivityResultContracts.RequestPermission()) { allowed -> + val action = permissionAction; permissionAction = null + if (allowed) action?.invoke() else status.setText(R.string.local_share_permission_denied) + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + val density = resources.displayMetrics.density + val layout = LinearLayout(this).apply { orientation = LinearLayout.VERTICAL; setPadding((24*density).toInt(),(24*density).toInt(),(24*density).toInt(),(24*density).toInt()) } + layout.addView(TextView(this).apply { setText(R.string.local_share_title); textSize = 26f }) + status = TextView(this).apply { setText(R.string.local_share_description); textSize = 16f; setPadding(0,16,0,24) } + layout.addView(status) + fun action(title: Int, run: () -> Unit) { layout.addView(Button(this).apply { setText(title); isAllCaps = false; setOnClickListener { withNetworkPermission(run) } }) } + action(R.string.local_share_receive, ::receive) + action(R.string.local_share_send_recent) { chooseRecent() } + if (intent.action == Intent.ACTION_SEND) action(R.string.local_share_send_title) { sendSharedText() } + layout.addView(Button(this).apply { setText(android.R.string.cancel); setOnClickListener { finish() } }) + setContentView(ScrollView(this).apply { addView(layout) }) + } + override fun onStart() { super.onStart(); active.set(true) } + override fun onStop() { active.set(false); stopNetwork(); super.onStop() } + private fun stopNetwork() { generation++; receiver?.close(); receiver = null; network?.close(); network = null } + + private fun withNetworkPermission(action: () -> Unit) { + if (Build.VERSION.SDK_INT >= 37 && applicationInfo.targetSdkVersion >= 37 && + ContextCompat.checkSelfPermission(this,"android.permission.ACCESS_LOCAL_NETWORK") != android.content.pm.PackageManager.PERMISSION_GRANTED) { + permissionAction = action; permission.launch("android.permission.ACCESS_LOCAL_NETWORK") + } else action() + } + + private fun receive() { + stopNetwork() + val currentGeneration = generation + status.setText(R.string.local_share_starting) + lifecycleScope.launch { + var created: LocalPairingTransport.Receiver? = null + try { + val session = withContext(Dispatchers.IO) { + LocalPairingTransport.Receiver(receive = { bytes -> + val transfer = SharedWatchTransfer.decode(bytes) + if (!active.get() || generation != currentGeneration || transfer == null) false else { + runOnUiThread { if (active.get() && generation == currentGeneration) confirm(transfer) }; true + } + }, ended = { runOnUiThread { + if (generation == currentGeneration) { + network?.close(); network = null + if (active.get()) status.setText(R.string.local_share_reception_finished) + } + } }).also { created = it } + } + if (!active.get() || generation != currentGeneration) { session.close(); return@launch } + receiver = session + val addresses = withContext(Dispatchers.IO) { + NetworkInterface.getNetworkInterfaces().toList().flatMap { it.inetAddresses.toList() } + .filter { !it.isLoopbackAddress && LocalPairingTransport.isLocal(it) } + .joinToString("\n") { address -> + val host = address.hostAddress.orEmpty() + if (':' in host) "[$host]:${session.port}" else "$host:${session.port}" + } + } + status.text = getString(R.string.local_share_receiver_code, session.code.chunked(4).joinToString("-"), addresses) + session.start() + runCatching { network = LocalDeviceDiscovery(this@LocalShareActivity) { _,_,_ -> }; network?.advertise(session.port) } + } catch (cancelled: kotlinx.coroutines.CancellationException) { throw cancelled } + catch (_: Exception) { stopNetwork(); status.setText(R.string.local_share_failed) } + finally { if (receiver !== created) created?.close() } + } + } + + private fun confirm(transfer: SharedWatchTransfer) { + AlertDialog.Builder(this).setTitle(transfer.title) + .setMessage(getString(R.string.local_share_confirm, transfer.apiName, transfer.position / 1000)) + .setPositiveButton(R.string.local_share_open) { _, _ -> + PendingWatchTransfer.put(transfer) + val api = Base64.encodeToString(transfer.apiName.toByteArray(Charsets.UTF_8), Base64.NO_WRAP) + val url = Base64.encodeToString(transfer.url.toByteArray(Charsets.UTF_8), Base64.NO_WRAP) + startActivity(Intent(this, MainActivity::class.java).setAction(Intent.ACTION_VIEW).setData(Uri.parse("csshare:$api?$url"))) + finish() + }.setNeutralButton(R.string.local_share_search) { _,_ -> + startActivity(Intent(this,MainActivity::class.java).setAction(Intent.ACTION_VIEW) + .setData(Uri.parse("cloudstreamsearch://"+Uri.encode(transfer.title)))) + finish() + }.setNegativeButton(android.R.string.cancel,null).show() + } + + private fun chooseRecent() { + lifecycleScope.launch { + val entries = HomeViewModel.getResumeWatching().orEmpty().take(50) + if (entries.isEmpty()) { status.setText(R.string.local_share_no_recent); return@launch } + AlertDialog.Builder(this@LocalShareActivity).setTitle(R.string.local_share_send_recent) + .setItems(entries.map { it.name }.toTypedArray()) { _, index -> + val entry = entries[index] + choosePeer(SharedWatchTransfer(title=entry.name, apiName=entry.apiName, url=entry.url, + season=entry.season, episode=entry.episode, position=entry.watchPos?.position ?: 0, + duration=entry.watchPos?.duration ?: 0)) + }.setNegativeButton(android.R.string.cancel,null).show() + } + } + + private fun sendSharedText() { + lifecycleScope.launch { + val raw = intent.getStringExtra(Intent.EXTRA_TEXT).orEmpty() + if (raw.length > 16_384) { status.setText(R.string.local_share_invalid); return@launch } + val parsedUri = Uri.parse(raw) + val link = if (parsedUri.isHierarchical) parsedUri.getQueryParameter("redirectto") ?: raw else raw + val parts = link.removePrefix("csshare:").split("?", limit=2) + val parsed = runCatching { + if (!link.startsWith("csshare:") || parts.size != 2) return@runCatching null + val api = String(Base64.decode(parts[0],Base64.DEFAULT),Charsets.UTF_8) + val url = String(Base64.decode(parts[1],Base64.DEFAULT),Charsets.UTF_8) + val recent = HomeViewModel.getResumeWatching().orEmpty().firstOrNull { it.apiName == api && it.url == url } + SharedWatchTransfer(title=intent.getStringExtra(Intent.EXTRA_SUBJECT)?.takeIf { it.isNotBlank() } ?: recent?.name ?: api, + apiName=api,url=url,season=recent?.season,episode=recent?.episode, + position=recent?.watchPos?.position ?: 0,duration=recent?.watchPos?.duration ?: 0) + }.getOrNull() + if (parsed?.isValid() == true) choosePeer(parsed) else status.setText(R.string.local_share_invalid) + } + } + + private fun choosePeer(transfer: SharedWatchTransfer) { + if (!transfer.isValid()) { status.setText(R.string.local_share_invalid); return } + stopNetwork() + val peers = linkedMapOf>() + val labels = arrayListOf() + val adapter = ArrayAdapter(this,android.R.layout.simple_list_item_1,labels) + val list = ListView(this).apply { this.adapter = adapter; minimumHeight = (160*resources.displayMetrics.density).toInt() } + val dialog = AlertDialog.Builder(this).setTitle(R.string.local_share_find_devices).setView(list) + .setNeutralButton(R.string.local_share_manual) { _, _ -> manualPeer(transfer) } + .setNegativeButton(android.R.string.cancel,null).create() + list.setOnItemClickListener { _,_,index,_ -> + val peer = peers[labels[index]] ?: return@setOnItemClickListener + dialog.dismiss(); askCode(transfer,peer.first,peer.second) + } + dialog.setOnDismissListener { network?.close(); network=null } + dialog.show() + runCatching { + network = LocalDeviceDiscovery(this) { name,address,port -> runOnUiThread { + if (active.get() && dialog.isShowing) { + if (!peers.containsKey(name)) labels.add(name) + peers[name]=address to port; adapter.notifyDataSetChanged() + } + } }; network?.discover() + } + } + + private fun manualPeer(transfer: SharedWatchTransfer) { + val input=EditText(this).apply { hint="192.168.1.5:12345"; inputType=InputType.TYPE_CLASS_TEXT } + AlertDialog.Builder(this).setTitle(R.string.local_share_manual).setView(input) + .setPositiveButton(R.string.local_share_continue) { _,_ -> val entered=input.text.toString().trim(); lifecycleScope.launch { + val endpoint=runCatching { withContext(Dispatchers.IO) { + val uri=java.net.URI("tcp://"+entered) + val host=InetAddress.getByName(uri.host ?: error("Missing address")) + require(LocalPairingTransport.isLocal(host) && uri.port in 1..65535) + host to uri.port + } }.getOrNull() + if(endpoint==null)status.setText(R.string.local_share_failed) else askCode(transfer,endpoint.first,endpoint.second) + } }.setNegativeButton(android.R.string.cancel,null).show() + } + + private fun askCode(transfer: SharedWatchTransfer,address:InetAddress,port:Int) { + val input=EditText(this).apply { inputType=InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_FLAG_CAP_CHARACTERS; filters=arrayOf(android.text.InputFilter.LengthFilter(14)) } + AlertDialog.Builder(this).setTitle(R.string.local_share_enter_code).setView(input) + .setPositiveButton(R.string.local_share_send) { _,_ -> val entered=input.text.toString(); lifecycleScope.launch { + val sent=runCatching { withContext(Dispatchers.IO) { + LocalPairingTransport.send(address,port,entered,transfer.toJson().toByteArray(Charsets.UTF_8)) + } }.getOrDefault(false) + status.setText(if(sent) R.string.local_share_sent else R.string.local_share_failed) + } }.setNegativeButton(android.R.string.cancel,null).show() + } +} diff --git a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/local/SharedWatchTransfer.kt b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/local/SharedWatchTransfer.kt new file mode 100644 index 00000000000..151c742b441 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/local/SharedWatchTransfer.kt @@ -0,0 +1,79 @@ +package com.lagradost.cloudstream3.syncproviders.local + +import com.lagradost.cloudstream3.ui.result.ResultEpisode +import com.lagradost.cloudstream3.utils.DataStoreHelper +import java.net.URI +import java.util.concurrent.atomic.AtomicReference + +data class SharedWatchTransfer( + val version: Int = 1, + val title: String = "", + val apiName: String = "", + val url: String = "", + val season: Int? = null, + val episode: Int? = null, + val position: Long = 0, + val duration: Long = 0, +) { + @com.fasterxml.jackson.annotation.JsonIgnore + fun isValid(): Boolean = runCatching { + val address = URI(url) + version == 1 && title.isNotBlank() && title.length <= 256 && apiName.isNotBlank() && apiName.length <= 128 && + title.none(Char::isISOControl) && apiName.none(Char::isISOControl) && url.length <= 8192 && + address.scheme in setOf("http", "https") && !address.host.isNullOrBlank() && address.userInfo == null && + (season == null || season in 0..10_000) && (episode == null || episode in 1..100_000) && + position in 0..604_800_000 && duration in 0..604_800_000 && (duration == 0L || position <= duration) + }.getOrDefault(false) + + companion object { + fun decode(bytes: ByteArray): SharedWatchTransfer? = runCatching { + if (bytes.size > 16_384) return null + val fields = hashMapOf() + com.fasterxml.jackson.core.JsonFactory().createParser(bytes).use { parser -> + if (parser.nextToken() != com.fasterxml.jackson.core.JsonToken.START_OBJECT) return null + while (parser.nextToken() != com.fasterxml.jackson.core.JsonToken.END_OBJECT) { + if (parser.currentToken != com.fasterxml.jackson.core.JsonToken.FIELD_NAME) return null + val name = parser.currentName + if (fields.containsKey(name)) return null + val token = parser.nextToken() + fields[name] = when (name) { + "title", "apiName", "url" -> if (token == com.fasterxml.jackson.core.JsonToken.VALUE_STRING) parser.text else return null + "version", "position", "duration" -> if (token == com.fasterxml.jackson.core.JsonToken.VALUE_NUMBER_INT) parser.longValue else return null + "season", "episode" -> when (token) { + com.fasterxml.jackson.core.JsonToken.VALUE_NULL -> null + com.fasterxml.jackson.core.JsonToken.VALUE_NUMBER_INT -> parser.longValue.takeIf { it in 0..100_000 }?.toInt() ?: return null + else -> return null + } + else -> return null + } + } + if (parser.nextToken() != null) return null + } + SharedWatchTransfer(version=(fields["version"] as? Long)?.takeIf { it == 1L }?.toInt() ?: return null, + title=fields["title"] as? String ?: return null,apiName=fields["apiName"] as? String ?: return null, + url=fields["url"] as? String ?: return null,season=fields["season"] as? Int,episode=fields["episode"] as? Int, + position=fields["position"] as? Long ?: 0,duration=fields["duration"] as? Long ?: 0).takeIf { it.isValid() } + }.getOrNull() + } +} + +internal object PendingWatchTransfer { + private data class Pending(val value: SharedWatchTransfer, val account: String, val expires: Long) + private val pending = AtomicReference(null) + fun put(value: SharedWatchTransfer) { + require(value.isValid()) + pending.set(Pending(value, DataStoreHelper.currentAccount, System.nanoTime() + 600_000_000_000L)) + } + fun apply(apiName: String, url: String, parentId: Int, movie: Boolean, episodes: List) { + val entry = pending.get() ?: return + if (entry.account != DataStoreHelper.currentAccount || System.nanoTime() > entry.expires) { pending.compareAndSet(entry,null); return } + val value = entry.value + if (value.apiName != apiName || value.url != url) return + val episode = episodes.firstOrNull { movie || (it.episode == value.episode && it.season == value.season) } ?: return + if (!pending.compareAndSet(entry, null)) return + if (value.position > 0) { + DataStoreHelper.setViewPos(episode.id, value.position, value.duration) + DataStoreHelper.setLastWatched(parentId, episode.id, episode.episode, episode.season) + } + } +} diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultViewModel2.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultViewModel2.kt index 17a698de2df..5b03c2c682e 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultViewModel2.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultViewModel2.kt @@ -2410,6 +2410,12 @@ class ResultViewModel2 : ViewModel() { private fun resume(): ResumeWatchingStatus? { val correctId = currentId ?: return null + currentResponse?.let { response -> + com.lagradost.cloudstream3.syncproviders.local.PendingWatchTransfer.apply( + response.apiName, response.url, correctId, response.isMovie(), + currentEpisodes.filter { preferDubStatus == null || it.key.dubStatus == preferDubStatus }.values.flatten() + ) + } val resume = getLastWatched(correctId) val resumeParentId = resume?.parentId if (resumeParentId != correctId) return null // is null or smth went wrong with getLastWatched diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsGeneral.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsGeneral.kt index 32f60752ea0..e65840bd9e4 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsGeneral.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsGeneral.kt @@ -277,6 +277,10 @@ class SettingsGeneral : BasePreferenceFragmentCompat() { override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { hideKeyboard() setPreferencesFromResource(R.xml.settings_general, rootKey) + findPreference("local_share")?.setOnPreferenceClickListener { + startActivity(android.content.Intent(requireContext(), com.lagradost.cloudstream3.syncproviders.local.LocalShareActivity::class.java)) + true + } val settingsManager = PreferenceManager.getDefaultSharedPreferences(requireContext()) diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsGeneralScreen.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsGeneralScreen.kt index 6aa94fa883c..be561fd0d52 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsGeneralScreen.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsGeneralScreen.kt @@ -114,6 +114,16 @@ object SettingsGeneralScreen : SearchableSettings { }.sortedBy { fromTagToLanguageName(it) ?: it } return persistentListOf( + Preference.PreferenceGroup( + title = stringResource(R.string.local_device_options_title), + preferenceItems = persistentListOf( + Preference.PreferenceItem.TextPreference( + title = stringResource(R.string.local_share_title), + subtitle = stringResource(R.string.local_share_description), + onClick = { context.startActivity(Intent(context, com.lagradost.cloudstream3.syncproviders.local.LocalShareActivity::class.java)) } + ), + ), + ), Preference.PreferenceGroup(title = stringResource(R.string.extension_language), preferenceItems = persistentListOf( Preference.PreferenceItem.ListPreference( preference = settings.general.locale, diff --git a/app/src/main/res/values-es/local_device_options.xml b/app/src/main/res/values-es/local_device_options.xml new file mode 100644 index 00000000000..c880f8a9f4a --- /dev/null +++ b/app/src/main/res/values-es/local_device_options.xml @@ -0,0 +1,3 @@ + + Compartir con dispositivos cercanos + diff --git a/app/src/main/res/values-es/local_share.xml b/app/src/main/res/values-es/local_share.xml new file mode 100644 index 00000000000..21e95cf76e3 --- /dev/null +++ b/app/src/main/res/values-es/local_share.xml @@ -0,0 +1,23 @@ + + Compartir con otro dispositivo + Envía un título y su progreso en la red local. El receptor debe confirmar. + Acceso a la red local denegado. + Recibir con código + Enviar desde Seguir viendo + Enviar este título + Preparando recepción… + Recepción finalizada. Inicia de nuevo para obtener otro código. + Código: %1$s\nVálido por dos minutos y un envío.\n%2$s + El envío falló. Revisa código y red y reintenta. + Proveedor: %1$s\nProgreso: %2$d segundos. ¿Abrir título? + Abrir título + Buscar título + No hay títulos en Seguir viendo. + No se puede compartir este título o enlace. + Elige un receptor + Introducir IP local y puerto + Continuar + Introduce los 12 caracteres del receptor + Enviar + Entregado. Confirma en el dispositivo receptor. + diff --git a/app/src/main/res/values/local_device_options.xml b/app/src/main/res/values/local_device_options.xml new file mode 100644 index 00000000000..6381146401c --- /dev/null +++ b/app/src/main/res/values/local_device_options.xml @@ -0,0 +1,3 @@ + + Sharing with nearby devices + diff --git a/app/src/main/res/values/local_share.xml b/app/src/main/res/values/local_share.xml new file mode 100644 index 00000000000..05ea333aac0 --- /dev/null +++ b/app/src/main/res/values/local_share.xml @@ -0,0 +1,23 @@ + + Share with another device + Send one title and its progress on your local network. The receiver must confirm. + Local network access denied. + Receive with code + Send from Continue watching + Send this title + Preparing reception… + Reception finished. Start again for a new code. + Code: %1$s\nValid for two minutes, one transfer.\n%2$s + Transfer failed. Check code and network, then retry. + Source: %1$s\nProgress: %2$d seconds. Open title? + Open title + Search title + No titles in Continue watching. + This title or link cannot be shared. + Choose a receiver + Enter local IP and port + Continue + Enter the 12-character receiver code + Send + Delivered. Confirm on the receiving device. + diff --git a/app/src/main/res/xml/settings_general.xml b/app/src/main/res/xml/settings_general.xml index f8b9b4f452f..e1e5ae60140 100644 --- a/app/src/main/res/xml/settings_general.xml +++ b/app/src/main/res/xml/settings_general.xml @@ -1,6 +1,9 @@ + + + () + val ended = CountDownLatch(1) + LocalPairingTransport.Receiver({ values.add(String(it)); true }, { ended.countDown() }).use { receiver -> + val port = receiver.port + receiver.start() + assertTrue(LocalPairingTransport.send(loopback, port, receiver.code.chunked(4).joinToString("-"), "title".toByteArray())) + assertTrue(ended.await(2, TimeUnit.SECONDS)) + assertEquals(listOf("title"), values) + assertTrue(runCatching { Socket(loopback, port).close() }.isFailure) + } + } + + @Test fun incorrectCodeDoesNotDeliverAndCanBeRetried() { + var received = 0 + LocalPairingTransport.Receiver({ received++; true }).use { receiver -> + receiver.start() + val wrong = if (receiver.code.startsWith("A")) "B" + receiver.code.drop(1) else "A" + receiver.code.drop(1) + assertFalse(runCatching { LocalPairingTransport.send(loopback, receiver.port, wrong, byteArrayOf(1)) }.getOrDefault(false)) + assertEquals(0, received) + assertTrue(LocalPairingTransport.send(loopback, receiver.port, receiver.code, byteArrayOf(2))) + assertEquals(1, received) + } + } + + @Test fun receiverMayRejectInvalidPayloadWithoutEndingSession() { + LocalPairingTransport.Receiver({ String(it) == "valid" }).use { receiver -> + receiver.start() + assertFalse(LocalPairingTransport.send(loopback, receiver.port, receiver.code, "invalid".toByteArray())) + assertTrue(LocalPairingTransport.send(loopback, receiver.port, receiver.code, "valid".toByteArray())) + } + } + + @Test fun closeInterruptsIncompleteConnection() { + val ended = CountDownLatch(1) + val receiver = LocalPairingTransport.Receiver({ fail("Unexpected delivery"); false }, { ended.countDown() }) + receiver.start() + Socket(loopback, receiver.port).use { socket -> + socket.soTimeout = 1000 + val input = java.io.DataInputStream(socket.getInputStream()) + input.readInt() + repeat(2) { input.readFully(ByteArray(input.readInt())) } + receiver.close() + assertEquals(-1, input.read()) + } + assertTrue(ended.await(2, TimeUnit.SECONDS)) + } + + @Test fun publicDestinationIsRejectedBeforeConnection() { + assertFalse(LocalPairingTransport.isLocal(InetAddress.getByName("8.8.8.8"))) + assertTrue(runCatching { LocalPairingTransport.send(InetAddress.getByName("8.8.8.8"),80,"A".repeat(12), byteArrayOf(1)) }.exceptionOrNull() is IllegalArgumentException) + } +} diff --git a/app/src/test/java/com/lagradost/cloudstream3/syncproviders/local/SharedWatchTransferTest.kt b/app/src/test/java/com/lagradost/cloudstream3/syncproviders/local/SharedWatchTransferTest.kt new file mode 100644 index 00000000000..01a39430c08 --- /dev/null +++ b/app/src/test/java/com/lagradost/cloudstream3/syncproviders/local/SharedWatchTransferTest.kt @@ -0,0 +1,31 @@ +package com.lagradost.cloudstream3.syncproviders.local + +import org.junit.Assert.* +import org.junit.Test + +class SharedWatchTransferTest { + private val valid = """{"version":1,"title":"Film","apiName":"Source","url":"https://example.com/film","position":25000,"duration":100000}""" + @Test fun readsTitleAndProgress() { + val transfer = SharedWatchTransfer.decode(valid.toByteArray()) + assertNotNull(transfer) + assertEquals(25000L, transfer!!.position) + } + @Test fun rejectsNestedOrUnknownFields() { + assertNull(SharedWatchTransfer.decode(valid.replace("25000", "{\"@type\":\"danger\"}").toByteArray())) + assertNull(SharedWatchTransfer.decode(valid.replace("\"version\":1", "\"@class\":\"example\",\"version\":1").toByteArray())) + } + @Test fun rejectsAmbiguousOrUnsupportedVersion() { + assertNull(SharedWatchTransfer.decode(valid.replace("\"version\":1", "\"version\":1,\"version\":1").toByteArray())) + assertNull(SharedWatchTransfer.decode(valid.replace("\"version\":1", "\"version\":2").toByteArray())) + assertNull(SharedWatchTransfer.decode((valid+"{}").toByteArray())) + } + @Test fun rejectsInvalidPositionsOrUrls() { + assertNull(SharedWatchTransfer.decode(valid.replace("25000", "-1").toByteArray())) + assertNull(SharedWatchTransfer.decode(valid.replace("25000", "100001").toByteArray())) + assertNull(SharedWatchTransfer.decode(valid.replace("https://example.com/film", "file:///secret").toByteArray())) + assertNull(SharedWatchTransfer.decode(valid.replace("https://example.com/film", "https://user:password@example.com").toByteArray())) + } + @Test fun boundsInputBeforeParsing() { + assertNull(SharedWatchTransfer.decode(ByteArray(16385))) + } +}