package com.boxlabs.hexdroidirc
import android.content.Context
import android.net.Uri
import androidx.core.content.FileProvider
import kotlin.concurrent.thread
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.io.BufferedOutputStream
import java.io.File
import java.io.FileOutputStream
import java.io.IOException
import java.nio.ByteOrder
import java.net.Inet4Address
import java.net.NetworkInterface
import java.net.ServerSocket
import java.net.Socket
import java.nio.ByteBuffer
import java.util.Collections
import java.util.concurrent.atomic.AtomicLong
/**
* Parsed CTCP DCC SEND offer.
*
* Supports classic (active) DCC and passive/reverse DCC (token + port 0 handshake).
*/
data class DccOffer(
/** Network id this offer belongs to (filled by the ViewModel). */
val port: Int,
// Passive/reverse DCC token
// Turbo DCC / TSEND: do not send ACKs back to the sender.
) {
val isPassive
: Boolean get
() = port
== 0 && token
!= null
}
sealed class DccTransferState {
data class Incoming(
val offer: DccOffer,
) : DccTransferState()
data class Outgoing(
) : DccTransferState()
}
// Avoid leaking an Activity context.
private val ctx
: Context = ctx.
applicationContext
fun dccDir
(): File = File(ctx.
filesDir,
"dcc").
apply { mkdirs
() }
// local IPv4 in dotted notation (may be private RFC1918)
fun localIpv4OrNull
(): String? {
return try {
val ifaces
= Collections.
list(NetworkInterface.
getNetworkInterfaces())
for (iface in ifaces) {
if (!iface.isUp || iface.isLoopback) continue
for (a in addrs) {
if (a is Inet4Address && !a.isLoopbackAddress && !a.isLinkLocalAddress) {
return a.hostAddress
}
}
}
null
null
}
}
// local IPv4 as an unsigned 32-bit integer (decimal string in CTCP)
fun localIpv4AsInt
(): Long {
val ip = localIpv4OrNull() ?: return 0L
return ipv4ToLongBestEffort(ip)
}
// Standard DCC RECEIVE (we connect to sender's ip:port).
suspend fun receive(
offer: DccOffer,
): File = withContext
(Dispatchers.
IO) {
val safeName = offer.filename.substringAfterLast('/').substringAfterLast('\\')
val outFile
= File(dccDir
(), safeName
)
Socket(offer.
ip, offer.
port).
use { sock
->
receiveFromSocket(sock, outFile, offer.size, offer.turbo, onProgress)
}
outFile
}
/**
* Passive/Reverse DCC RECEIVE.
*
* The remote sender offered port 0 + token. We open a listening port, reply with the port,
* then accept the incoming connection and receive.
*/
suspend fun receivePassive(
offer: DccOffer,
portMin: Int,
portMax: Int,
onListening
: suspend
(ipAsInt
: Long, port
: Int, size
: Long, token
: Long) -> Unit,
): File = withContext
(Dispatchers.
IO) {
val safeName = offer.filename.substringAfterLast('/').substringAfterLast('\\')
val outFile
= File(dccDir
(), safeName
)
val ss = bindFirstAvailable(portMin, portMax)
try {
ss.soTimeout = 45_000
val ipInt = localIpv4AsInt()
onListening(ipInt, ss.localPort, offer.size, token)
val sock = try {
ss.accept()
} catch (_: java.net.SocketTimeoutException) {
}
sock.use { s ->
receiveFromSocket(s, outFile, offer.size, offer.turbo, onProgress)
}
} finally {
runCatching { ss.close() }
}
outFile
}
// Active DCC SEND: we listen on a port in portMin/portMax and send when peer connects.
suspend fun sendFile(
portMin: Int,
portMax: Int,
onClient
: suspend
(ipAsInt
: Long, port
: Int, size
: Long) -> Unit,
): Unit = withContext(Dispatchers.IO) {
val size = file.length()
val ss = bindFirstAvailable(portMin, portMax)
try {
val ipInt = localIpv4AsInt()
onClient(ipInt, ss.localPort, size)
ss.soTimeout = 45_000
val sock = try {
ss.accept()
} catch (_: java.net.SocketTimeoutException) {
}
sock.use { s ->
sendOverSocket(s, file, size, onProgress)
}
} finally {
runCatching { ss.close() }
}
}
//Passive/Reverse DCC SEND: peer opened a port; we connect out and send.
suspend fun sendFileConnect(
port: Int,
): Unit = withContext(Dispatchers.IO) {
val size = file.length()
sendOverSocket(s, file, size, onProgress)
}
}
private fun receiveFromSocket(
) {
sock.tcpNoDelay = true
sock.getInputStream().use { inp ->
val buf = ByteArray(32 * 1024)
var total = 0L
val expected
: Long? = expectedSize.
takeIf { it
> 0L
}
while (true) {
val n = inp.read(buf)
if (n <= 0) break
fos.write(buf, 0, n)
total += n
onProgress(total, expectedSize)
if (!turbo) {
// DCC SEND expects an ACK of total bytes received (4-byte unsigned int, network byte order).
val ackInt = total.coerceAtMost(0xFFFFFFFFL).toInt()
val ack = ByteBuffer.allocate(4).putInt(ackInt).array()
runCatching { sock.getOutputStream().write(ack) }
}
if (expected != null && total >= expected) break
}
}
}
}
private fun sendOverSocket(
) {
sock.tcpNoDelay = true
// Used by the ACK reader thread.
sock.soTimeout = 1_000
val acked = AtomicLong(0L)
fun u32be
(b
: ByteArray
): Long =
(ByteBuffer.wrap(b).order(ByteOrder.BIG_ENDIAN).int.toLong() and 0xFFFFFFFFL)
fun u32le
(b
: ByteArray
): Long =
(ByteBuffer.wrap(b).order(ByteOrder.LITTLE_ENDIAN).int.toLong() and 0xFFFFFFFFL)
// DCC ACKs are commonly network byte order, but some clients historically send host order.
// We choose the value that is monotonic and plausible for this transfer size.
val cands = sequenceOf(be, le).distinct().filter { it >= last }.toList()
if (cands.isEmpty()) return null
if (size > 0L) {
// Prefer candidates <= size.
val inRange = cands.filter { it <= size }
if (inRange.isNotEmpty()) return inRange.maxOrNull()
// Some clients may overshoot slightly; clamp.
val near = cands.filter { it <= size + 1024 * 1024L }
if (near.isNotEmpty()) return size
}
return cands.maxOrNull()
}
val ackThread = thread(start = true, isDaemon = true, name = "dcc-ack-reader") {
val inp = sock.getInputStream()
val b = ByteArray(4)
var off = 0
var last = 0L
while (!Thread.
currentThread().
isInterrupted) {
try {
val n = inp.read(b, off, 4 - off)
if (n < 0) break
off += n
if (off == 4) {
val be = u32be(b)
val le = u32le(b)
val chosen = chooseAck(be, le, last)
if (chosen != null) {
last = chosen
acked.set(chosen)
}
off = 0
}
} catch (_: java.net.SocketTimeoutException) {
// keep polling
break
}
}
}
var sent = 0L
try {
val outRaw = sock.getOutputStream()
val buf = ByteArray(32 * 1024)
file.inputStream().use { fin ->
while (true) {
val n = fin.read(buf)
if (n <= 0) break
try {
out.write(buf, 0, n)
sent += n
onProgress(sent, size)
// If the peer already ACKed the full size, treat as success.
if (size > 0L && acked.get() >= size) break
throw io
}
}
}
out.flush()
// Half-close so receiver sees EOF; then wait briefly for final ACK/peer close.
runCatching { sock.shutdownOutput() }
val deadline
= System.
currentTimeMillis() + 10_000L
while (size
> 0L
&& acked.
get() < size
&& System.
currentTimeMillis() < deadline
) {
// If the receiver closed, the ACK thread will stop.
if (!ackThread.isAlive) break
}
} finally {
runCatching { ackThread.interrupt() }
}
}
private fun bindFirstAvailable
(min
: Int, max
: Int
): ServerSocket {
val a = min.coerceIn(1, 65535)
val b = max.coerceIn(1, 65535)
for (p in a..b) {
try {
// try next
}
}
}
private fun ipv4ToLongBestEffort
(ip
: String): Long {
val parts = ip.split(".")
if (parts.size != 4) return 0L
return try {
var out = 0L
for (p in parts) out = (out shl 8) or (p.toLong() and 0xFFL)
out
0L
}
}
@Suppress("unused")
fun contentUriForFile
(file
: File): Uri
=
FileProvider.getUriForFile(ctx, "${ctx.packageName}.fileprovider", file)
}