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)))
+ }
+}