From d28804178e6ed5cf0203c95dc67a718f2745233e Mon Sep 17 00:00:00 2001 From: acite <1498045907@qq.com> Date: Sat, 13 Sep 2025 03:15:08 +0800 Subject: [PATCH] =?UTF-8?q?[feat]=20Abyss=20'HELLY'=20encryption=20protoco?= =?UTF-8?q?l=20=F0=9F=98=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/build.gradle.kts | 1 + app/src/main/AndroidManifest.xml | 13 +- .../com/acitelight/aether/AbyssService.kt | 40 ++ .../java/com/acitelight/aether/AetherApp.kt | 36 +- .../com/acitelight/aether/MainActivity.kt | 20 + .../java/com/acitelight/aether/model/Comic.kt | 2 +- .../java/com/acitelight/aether/model/Video.kt | 6 +- .../acitelight/aether/service/AbyssStream.kt | 357 ++++++++++++++++++ .../aether/service/AbyssTunnelProxy.kt | 116 ++++++ .../acitelight/aether/service/ApiClient.kt | 89 ++++- .../aether/viewModel/HomeScreenViewModel.kt | 7 +- .../aether/viewModel/MeScreenViewModel.kt | 6 +- 12 files changed, 665 insertions(+), 28 deletions(-) create mode 100644 app/src/main/java/com/acitelight/aether/AbyssService.kt create mode 100644 app/src/main/java/com/acitelight/aether/service/AbyssStream.kt create mode 100644 app/src/main/java/com/acitelight/aether/service/AbyssTunnelProxy.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 2f1bfe3..86f593d 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -38,6 +38,7 @@ android { } kotlinOptions { jvmTarget = "21" + freeCompilerArgs = listOf("-XXLanguage:+PropertyParamAnnotationDefaultTargetMode") } buildFeatures { compose = true diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 6d2ab95..4723206 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -21,13 +21,22 @@ - + + + + + \ No newline at end of file diff --git a/app/src/main/java/com/acitelight/aether/AbyssService.kt b/app/src/main/java/com/acitelight/aether/AbyssService.kt new file mode 100644 index 0000000..e133218 --- /dev/null +++ b/app/src/main/java/com/acitelight/aether/AbyssService.kt @@ -0,0 +1,40 @@ +package com.acitelight.aether + +import android.app.Service +import android.content.Intent +import android.os.Binder +import android.os.IBinder +import com.acitelight.aether.service.AbyssTunnelProxy +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch + +class AbyssService : Service() { + private val binder = AbyssServiceBinder() + private val _isInitialized = MutableStateFlow(false) + val isInitialized: StateFlow = _isInitialized.asStateFlow() + private val serviceScope = CoroutineScope(Dispatchers.IO + Job()) + var proxy = AbyssTunnelProxy() + + inner class AbyssServiceBinder : Binder() { + fun getService(): AbyssService = this@AbyssService + } + + override fun onBind(intent: Intent?): IBinder { + return binder + } + + override fun onCreate() { + super.onCreate() + + serviceScope.launch { + _isInitialized.update { true } + proxy.start() + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/acitelight/aether/AetherApp.kt b/app/src/main/java/com/acitelight/aether/AetherApp.kt index 7cc7d0c..ff3a90b 100644 --- a/app/src/main/java/com/acitelight/aether/AetherApp.kt +++ b/app/src/main/java/com/acitelight/aether/AetherApp.kt @@ -1,17 +1,51 @@ package com.acitelight.aether import android.app.Application +import android.content.ComponentName import android.content.Context +import android.content.Intent +import android.content.ServiceConnection +import android.os.IBinder import androidx.datastore.core.DataStore import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.preferencesDataStore +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn import dagger.hilt.android.HiltAndroidApp +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent +import kotlinx.coroutines.flow.StateFlow +import javax.inject.Singleton val Context.dataStore: DataStore by preferencesDataStore(name = "configure") @HiltAndroidApp class AetherApp : Application() { + var abyssService: AbyssService? = null + var isServiceBound = false + private set + + val isServiceInitialized: StateFlow? + get() = abyssService?.isInitialized + + private val serviceConnection = object : ServiceConnection { + override fun onServiceConnected(name: ComponentName?, service: IBinder?) { + val binder = service as AbyssService.AbyssServiceBinder + abyssService = binder.getService() + isServiceBound = true + } + + override fun onServiceDisconnected(name: ComponentName?) { + isServiceBound = false + abyssService = null + } + } + override fun onCreate() { super.onCreate() + + val intent = Intent(this, AbyssService::class.java) + bindService(intent, serviceConnection, Context.BIND_AUTO_CREATE) } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/acitelight/aether/MainActivity.kt b/app/src/main/java/com/acitelight/aether/MainActivity.kt index 7f85263..1a0b698 100644 --- a/app/src/main/java/com/acitelight/aether/MainActivity.kt +++ b/app/src/main/java/com/acitelight/aether/MainActivity.kt @@ -1,6 +1,7 @@ package com.acitelight.aether import android.app.Activity +import android.content.Intent import androidx.compose.material.icons.Icons import android.graphics.drawable.Icon import android.net.http.SslCertificate.saveState @@ -39,6 +40,7 @@ import androidx.compose.ui.unit.dp import androidx.core.view.WindowCompat import androidx.core.view.WindowInsetsCompat import androidx.core.view.WindowInsetsControllerCompat +import androidx.lifecycle.lifecycleScope import androidx.navigation.NavController import androidx.navigation.NavGraph.Companion.findStartDestination import androidx.navigation.NavType @@ -57,6 +59,9 @@ import com.acitelight.aether.view.VideoPlayer import com.acitelight.aether.view.VideoScreen import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import okhttp3.OkHttpClient import okhttp3.Request @@ -66,6 +71,21 @@ class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) + val app = application as AetherApp + + lifecycleScope.launch { + app.isServiceInitialized?.filter { it }?.first() + val intent = Intent(this@MainActivity, MainScreenActivity::class.java) + startActivity(intent) + finish() + } + } +} + +@AndroidEntryPoint +class MainScreenActivity : ComponentActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) window.attributes = window.attributes.apply { screenBrightness = WindowManager.LayoutParams.BRIGHTNESS_OVERRIDE_NONE } diff --git a/app/src/main/java/com/acitelight/aether/model/Comic.kt b/app/src/main/java/com/acitelight/aether/model/Comic.kt index a6819d7..65fd634 100644 --- a/app/src/main/java/com/acitelight/aether/model/Comic.kt +++ b/app/src/main/java/com/acitelight/aether/model/Comic.kt @@ -10,7 +10,7 @@ class Comic( { fun getPage(pageNumber: Int): String { - return "${ApiClient.base}api/image/$id/${comic.list[pageNumber]}?token=$token" + return "${ApiClient.getBase()}api/image/$id/${comic.list[pageNumber]}?token=$token" } fun getPage(pageName: String): String? diff --git a/app/src/main/java/com/acitelight/aether/model/Video.kt b/app/src/main/java/com/acitelight/aether/model/Video.kt index 66a8aa6..598f17e 100644 --- a/app/src/main/java/com/acitelight/aether/model/Video.kt +++ b/app/src/main/java/com/acitelight/aether/model/Video.kt @@ -11,18 +11,18 @@ class Video constructor( ){ fun getCover(): String { - return "${ApiClient.base}api/video/$klass/$id/cover?token=$token" + return "${ApiClient.getBase()}api/video/$klass/$id/cover?token=$token" } fun getVideo(): String { - return "${ApiClient.base}api/video/$klass/$id/av?token=$token" + return "${ApiClient.getBase()}api/video/$klass/$id/av?token=$token" } fun getGallery(): List { return video.gallery.map{ - KeyImage(url = "${ApiClient.base}api/video/$klass/$id/gallery/$it?token=$token", key = "$klass/$id/gallery/$it") + KeyImage(url = "${ApiClient.getBase()}api/video/$klass/$id/gallery/$it?token=$token", key = "$klass/$id/gallery/$it") } } diff --git a/app/src/main/java/com/acitelight/aether/service/AbyssStream.kt b/app/src/main/java/com/acitelight/aether/service/AbyssStream.kt new file mode 100644 index 0000000..e061cf9 --- /dev/null +++ b/app/src/main/java/com/acitelight/aether/service/AbyssStream.kt @@ -0,0 +1,357 @@ +package com.acitelight.aether.service + +import kotlinx.coroutines.* +import java.io.InputStream +import java.io.OutputStream + +import java.net.Socket +import java.nio.ByteBuffer +import java.security.SecureRandom +import java.util.ArrayDeque + +import org.bouncycastle.math.ec.rfc7748.X25519 +import org.bouncycastle.crypto.generators.HKDFBytesGenerator +import org.bouncycastle.crypto.digests.SHA256Digest +import org.bouncycastle.crypto.params.HKDFParameters +import org.bouncycastle.crypto.params.KeyParameter +import org.bouncycastle.crypto.params.AEADParameters +import org.bouncycastle.crypto.modes.ChaCha20Poly1305 +import java.io.EOFException +import java.util.concurrent.atomic.AtomicLong + +class AbyssStream private constructor( + private val socket: Socket, + private val input: InputStream, + private val output: OutputStream, + private val aeadKey: ByteArray, + private val sendSalt: ByteArray, + private val recvSalt: ByteArray +) { + companion object { + private const val PUBLIC_KEY_LEN = 32 + private const val AEAD_KEY_LEN = 32 + private const val NONCE_SALT_LEN = 4 + private const val AEAD_TAG_LEN = 16 + private const val NONCE_LEN = 12 + private const val MAX_PLAINTEXT_FRAME = 64 * 1024 + + private val secureRandom = SecureRandom() + + /** + * Create and perform handshake on an already-connected socket. + * If privateKeyRaw is provided, it must be 32 bytes. + */ + suspend fun create(socket: Socket, privateKeyRaw: ByteArray? = null): AbyssStream = withContext(Dispatchers.IO) { + if (!socket.isConnected) throw IllegalArgumentException("socket is not connected") + val inStream = socket.getInputStream() + val outStream = socket.getOutputStream() + + // 1) keypair (raw) + val localPriv = ByteArray(PUBLIC_KEY_LEN) + if (privateKeyRaw != null) { + if (privateKeyRaw.size != PUBLIC_KEY_LEN) { + throw IllegalArgumentException("privateKeyRaw must be $PUBLIC_KEY_LEN bytes") + } + System.arraycopy(privateKeyRaw, 0, localPriv, 0, PUBLIC_KEY_LEN) + } else { + X25519.generatePrivateKey(secureRandom, localPriv) + } + val localPub = ByteArray(PUBLIC_KEY_LEN) + X25519.scalarMultBase(localPriv, 0, localPub, 0) + + // 2) exchange raw public keys (exact 32 bytes each) using blocking IO + writeExact(outStream, localPub, 0, PUBLIC_KEY_LEN) + val remotePub = ByteArray(PUBLIC_KEY_LEN) + readExact(inStream, remotePub, 0, PUBLIC_KEY_LEN) + + // 3) compute shared secret: X25519.scalarMult(private, remotePublic) + val shared = ByteArray(PUBLIC_KEY_LEN) + X25519.scalarMult(localPriv, 0, remotePub, 0, shared, 0) + + // 4) HKDF-SHA256 -> AEAD key + saltA + saltB + val hkdf = HKDFBytesGenerator(SHA256Digest()) + // AEAD key + hkdf.init(HKDFParameters(shared, null, "Abyss-AEAD-Key".toByteArray(Charsets.US_ASCII))) + val aeadKey = ByteArray(AEAD_KEY_LEN) + hkdf.generateBytes(aeadKey, 0, AEAD_KEY_LEN) + + // salt A + hkdf.init(HKDFParameters(shared, null, "Abyss-Nonce-Salt-A".toByteArray(Charsets.US_ASCII))) + val saltA = ByteArray(NONCE_SALT_LEN) + hkdf.generateBytes(saltA, 0, NONCE_SALT_LEN) + + // salt B + hkdf.init(HKDFParameters(shared, null, "Abyss-Nonce-Salt-B".toByteArray(Charsets.US_ASCII))) + val saltB = ByteArray(NONCE_SALT_LEN) + hkdf.generateBytes(saltB, 0, NONCE_SALT_LEN) + + // Deterministic assignment by lexicographic comparison + val cmp = lexicographicCompare(localPub, remotePub) + val sendSalt: ByteArray + val recvSalt: ByteArray + if (cmp < 0) { + sendSalt = saltA + recvSalt = saltB + } else if (cmp > 0) { + sendSalt = saltB + recvSalt = saltA + } else { + // extremely unlikely + sendSalt = saltA + recvSalt = saltB + } + + // zero sensitive buffers + localPriv.fill(0) + localPub.fill(0) + remotePub.fill(0) + shared.fill(0) + // keep aeadKey, sendSalt, recvSalt + + return@withContext AbyssStream(socket, inStream, outStream, aeadKey, sendSalt, recvSalt) + } + + private fun lexicographicCompare(a: ByteArray, b: ByteArray): Int { + val min = kotlin.math.min(a.size, b.size) + for (i in 0 until min) { + val av = a[i].toInt() and 0xff + val bv = b[i].toInt() and 0xff + if (av < bv) return -1 + if (av > bv) return 1 + } + if (a.size < b.size) return -1 + if (a.size > b.size) return 1 + return 0 + } + + private fun readExact(input: InputStream, buffer: ByteArray, offset: Int, count: Int) { + var read = 0 + while (read < count) { + val n = input.read(buffer, offset + read, count - read) + if (n == -1) { + if (read == 0) throw EOFException("Remote closed connection while reading") + else throw EOFException("Remote closed connection unexpectedly during read") + } + read += n + } + } + + private fun writeExact(output: OutputStream, buffer: ByteArray, offset: Int, count: Int) { + output.write(buffer, offset, count) + output.flush() + } + } + + // internal state + private val sendCounter = AtomicLong(0L) + private val recvCounter = AtomicLong(0L) + private val sendLock = Any() + private val aeadLock = Any() + + // leftover read queue + private val leftoverQueue = ArrayDeque() + private var currentLeftover: ByteArray? = null + private var currentLeftoverOffset = 0 + + @Volatile + private var closed = false + + // ---- high-level read/write APIs (suspendable) ---- + + suspend fun read(buffer: ByteArray, offset: Int, count: Int): Int = withContext(Dispatchers.IO) { + if (closed) throw IllegalStateException("AbyssStream closed") + if (buffer.size < offset + count) throw IndexOutOfBoundsException() + // serve leftover first + if (ensureCurrentLeftover()) { + val seg = currentLeftover!! + val avail = seg.size - currentLeftoverOffset + val toCopy = kotlin.math.min(avail, count) + System.arraycopy(seg, currentLeftoverOffset, buffer, offset, toCopy) + currentLeftoverOffset += toCopy + if (currentLeftoverOffset >= seg.size) { + currentLeftover = null + currentLeftoverOffset = 0 + } + return@withContext toCopy + } + + // read one frame and decrypt + val plaintext = readOneFrameAndDecrypt() + if (plaintext == null || plaintext.isEmpty()) { + // EOF + return@withContext 0 + } + + return@withContext if (plaintext.size <= count) { + System.arraycopy(plaintext, 0, buffer, offset, plaintext.size) + plaintext.size + } else { + System.arraycopy(plaintext, 0, buffer, offset, count) + val leftoverLen = plaintext.size - count + val leftover = ByteArray(leftoverLen) + System.arraycopy(plaintext, count, leftover, 0, leftoverLen) + synchronized(leftoverQueue) { leftoverQueue.addLast(leftover) } + count + } + } + + private fun ensureCurrentLeftover(): Boolean { + if (currentLeftover != null && currentLeftoverOffset < currentLeftover!!.size) return true + synchronized(leftoverQueue) { + val next = leftoverQueue.pollFirst() + if (next != null) { + currentLeftover = next + currentLeftoverOffset = 0 + return true + } + } + return false + } + + private fun readOneFrameAndDecrypt(): ByteArray? { + // read 4-byte header + val header = ByteArray(4) + try { + readExact(input, header, 0, 4) + } catch (e: EOFException) { + return null + } + val payloadLen = ByteBuffer.wrap(header).int and 0xffffffff.toInt() + if (payloadLen > MAX_PLAINTEXT_FRAME + AEAD_TAG_LEN) throw IllegalStateException("payload too big") + if (payloadLen < AEAD_TAG_LEN) throw IllegalStateException("payload too small") + + val payload = ByteArray(payloadLen) + readExact(input, payload, 0, payloadLen) + + val ciphertextLen = payloadLen - AEAD_TAG_LEN + val ciphertext = ByteArray(ciphertextLen) + val tag = ByteArray(AEAD_TAG_LEN) + if (ciphertextLen > 0) System.arraycopy(payload, 0, ciphertext, 0, ciphertextLen) + System.arraycopy(payload, ciphertextLen, tag, 0, AEAD_TAG_LEN) + + val remoteCounterValue = recvCounter.getAndIncrement() + + val nonce = ByteArray(NONCE_LEN) + System.arraycopy(recvSalt, 0, nonce, 0, NONCE_SALT_LEN) + // write 8-byte big-endian counter at nonce[4..11] + val bb = ByteBuffer.wrap(nonce, NONCE_SALT_LEN, 8) + bb.putLong(remoteCounterValue) + + val plaintext = try { + aeadDecrypt(nonce, ciphertext, tag) + } catch (ex: Exception) { + close() + throw SecurityException("AEAD authentication failed; connection closed.", ex) + } finally { + nonce.fill(0) + payload.fill(0) + ciphertext.fill(0) + tag.fill(0) + } + + return plaintext + } + + suspend fun write(buffer: ByteArray, offset: Int, count: Int) = withContext(Dispatchers.IO) { + if (closed) throw IllegalStateException("AbyssStream closed") + if (buffer.size < offset + count) throw IndexOutOfBoundsException() + var remaining = count + var idx = offset + while (remaining > 0) { + val chunk = kotlin.math.min(remaining, MAX_PLAINTEXT_FRAME) + val plaintext = buffer.copyOfRange(idx, idx + chunk) + sendPlaintextChunk(plaintext) + idx += chunk + remaining -= chunk + } + } + + private fun sendPlaintextChunk(plaintext: ByteArray) { + if (closed) throw IllegalStateException("AbyssStream closed") + + val ciphertextAndTag: ByteArray + val nonce = ByteArray(NONCE_LEN) + val counterValue: Long + synchronized(sendLock) { + counterValue = sendCounter.getAndIncrement() + } + System.arraycopy(sendSalt, 0, nonce, 0, NONCE_SALT_LEN) + val bb = ByteBuffer.wrap(nonce, NONCE_SALT_LEN, 8) + bb.putLong(counterValue) + + try { + ciphertextAndTag = aeadEncrypt(nonce, plaintext) + } finally { + nonce.fill(0) + } + + val payloadLen = ciphertextAndTag.size + val header = ByteBuffer.allocate(4).putInt(payloadLen).array() + try { + synchronized(output) { + output.write(header) + if (payloadLen > 0) output.write(ciphertextAndTag) + output.flush() + } + } finally { + // clear sensitive + ciphertextAndTag.fill(0) + plaintext.fill(0) + } + } + + // ---- AEAD helpers using BouncyCastle lightweight API ---- + // ChaCha20-Poly1305 with 12-byte nonce. BouncyCastle ChaCha20Poly1305 produces ciphertext+tag. + + private fun aeadEncrypt(nonce: ByteArray, plaintext: ByteArray): ByteArray { + synchronized(aeadLock) { + val cipher = ChaCha20Poly1305() + val params = AEADParameters(KeyParameter(aeadKey), AEAD_TAG_LEN * 8, nonce, null) + cipher.init(true, params) + val outBuf = ByteArray(cipher.getOutputSize(plaintext.size)) + var len = cipher.processBytes(plaintext, 0, plaintext.size, outBuf, 0) + len += cipher.doFinal(outBuf, len) + if (len != outBuf.size) return outBuf.copyOf(len) + return outBuf + } + } + + private fun aeadDecrypt(nonce: ByteArray, ciphertext: ByteArray, tag: ByteArray): ByteArray { + synchronized(aeadLock) { + val cipher = ChaCha20Poly1305() + val params = AEADParameters(KeyParameter(aeadKey), AEAD_TAG_LEN * 8, nonce, null) + cipher.init(false, params) + // input is ciphertext||tag + val input = ByteArray(ciphertext.size + tag.size) + if (ciphertext.isNotEmpty()) System.arraycopy(ciphertext, 0, input, 0, ciphertext.size) + System.arraycopy(tag, 0, input, ciphertext.size, tag.size) + val outBuf = ByteArray(cipher.getOutputSize(input.size)) + var len = cipher.processBytes(input, 0, input.size, outBuf, 0) + try { + len += cipher.doFinal(outBuf, len) + } catch (ex: Exception) { + // authentication failure or other + throw ex + } + return if (len != outBuf.size) outBuf.copyOf(len) else outBuf + } + } + + // ---- utility / lifecycle ---- + + fun close() { + if (!closed) { + closed = true + try { socket.close() } catch (_: Exception) {} + // clear secrets + aeadKey.fill(0) + sendSalt.fill(0) + recvSalt.fill(0) + synchronized(leftoverQueue) { + leftoverQueue.forEach { it.fill(0) } + leftoverQueue.clear() + } + currentLeftover = null + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/acitelight/aether/service/AbyssTunnelProxy.kt b/app/src/main/java/com/acitelight/aether/service/AbyssTunnelProxy.kt new file mode 100644 index 0000000..6950695 --- /dev/null +++ b/app/src/main/java/com/acitelight/aether/service/AbyssTunnelProxy.kt @@ -0,0 +1,116 @@ +package com.acitelight.aether.service + + +import kotlinx.coroutines.* +import kotlinx.coroutines.selects.select +import java.io.InputStream +import java.io.OutputStream +import java.net.InetAddress +import java.net.ServerSocket +import java.net.Socket +import kotlin.coroutines.CoroutineContext + +class AbyssTunnelProxy( + private val coroutineContext: CoroutineContext = Dispatchers.IO +) { + private var serverHost: String = "" + private var serverPort: Int = 0 + + fun config(host: String, port: Int) + { + serverHost = host + serverPort = port + } + + private val listenAddress = InetAddress.getLoopbackAddress() + private val listenPort = 4095 + private var serverSocket: ServerSocket? = null + private val scope = CoroutineScope(SupervisorJob() + coroutineContext) + + fun start() { + serverSocket = ServerSocket(listenPort, 50, listenAddress) + // accept loop + scope.launch { + val srv = serverSocket ?: return@launch + try { + while (true) { + val client = srv.accept() + + if(serverHost.isEmpty()) + continue + + launch { + try { handleLocalConnection(client) } + catch (ex: Exception) { /* ignore */ } + } + } + } catch (ex: Exception) { + println(ex.message) + // server stopped or fatal error + } finally { + stop() + } + } + } + + fun stop() { + try { serverSocket?.close() } catch (_: Exception) {} + scope.cancel() + } + + private suspend fun handleLocalConnection(localSocket: Socket) = withContext(coroutineContext) { + val localIn = localSocket.getInputStream() + val localOut = localSocket.getOutputStream() + var abyssSocket: Socket? = null + var abyssStream: AbyssStream? = null + try { + abyssSocket = Socket(serverHost, serverPort) + abyssStream = AbyssStream.create(abyssSocket) + + // concurrently copy in both directions + val job1 = launch { copyExactSuspend(localIn, abyssStream) } // local -> abyss + val job2 = launch { copyFromAbyssToLocal(abyssStream, localOut) } // abyss -> local + + // wait for either direction to finish + select { + job1.onJoin { /* completed */ } + job2.onJoin { /* completed */ } + } + // cancel other + job1.cancel() + job2.cancel() + } catch (ex: Exception) + { + println(ex.message) + // log or ignore; we close sockets below + } finally { + try { localSocket.close() } catch (_: Exception) {} + try { abyssStream?.close() } catch (_: Exception) {} + try { abyssSocket?.close() } catch (_: Exception) {} + } + return@withContext + } + + // Copy from local InputStream into AbyssStream.write in frames. + private suspend fun copyExactSuspend(localIn: InputStream, abyss: AbyssStream) = withContext(coroutineContext) { + val buffer = ByteArray(16 * 1024) + while (true) { + val read = localIn.read(buffer) + if (read <= 0) + break + abyss.write(buffer, 0, read) + } + } + + // Copy from AbyssStream (read frames/decrypt) to local OutputStream + private suspend fun copyFromAbyssToLocal(abyss: AbyssStream, localOut: OutputStream) = withContext(coroutineContext) { + val buffer = ByteArray(16 * 1024) + while (true) { + val n = abyss.read(buffer, 0, buffer.size) + if (n <= 0) + break + localOut.write(buffer, 0, n) + localOut.flush() + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/acitelight/aether/service/ApiClient.kt b/app/src/main/java/com/acitelight/aether/service/ApiClient.kt index 937ae3f..67ebaa1 100644 --- a/app/src/main/java/com/acitelight/aether/service/ApiClient.kt +++ b/app/src/main/java/com/acitelight/aether/service/ApiClient.kt @@ -1,16 +1,25 @@ package com.acitelight.aether.service +import android.content.Context +import android.util.Log +import androidx.core.net.toUri +import com.acitelight.aether.AetherApp import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import kotlinx.serialization.json.Json +import okhttp3.ConnectionSpec +import okhttp3.EventListener import okhttp3.HttpUrl.Companion.toHttpUrlOrNull import okhttp3.MediaType.Companion.toMediaType import okhttp3.OkHttpClient import retrofit2.Retrofit import retrofit2.converter.gson.GsonConverterFactory import java.io.ByteArrayInputStream +import java.net.InetAddress +import java.net.InetSocketAddress +import java.net.Proxy import java.security.KeyStore import java.security.cert.CertificateException import java.security.cert.CertificateFactory @@ -18,19 +27,23 @@ import java.security.cert.X509Certificate import javax.net.ssl.SSLContext import javax.net.ssl.TrustManagerFactory import javax.net.ssl.X509TrustManager -import okhttp3.EventListener -import java.net.InetAddress -import android.util.Log -import okhttp3.ConnectionSpec + object ApiClient { - var base: String = "" + fun getBase(): String{ + return replaceAbyssProtocol(base) + } + private var base: String = "" var domain: String = "" var cert: String = "" private val json = Json { ignoreUnknownKeys = true } + fun replaceAbyssProtocol(uri: String): String { + return uri.replaceFirst("^abyss://".toRegex(), "https://") + } + private val dnsEventListener = object : EventListener() { override fun dnsEnd(call: okhttp3.Call, domainName: String, inetAddressList: List) { super.dnsEnd(call, domainName, inetAddressList) @@ -112,10 +125,32 @@ object ApiClient { init(null, arrayOf(combinedTm), null) } - return OkHttpClient.Builder() - .connectionSpecs(listOf(ConnectionSpec.MODERN_TLS, ConnectionSpec.COMPATIBLE_TLS)) - .sslSocketFactory(sslContext.socketFactory, combinedTm) - .build() + return if (base.startsWith("abyss://")) + OkHttpClient.Builder() + .connectionSpecs( + listOf( + ConnectionSpec.MODERN_TLS, + ConnectionSpec.COMPATIBLE_TLS + ) + ) + .proxy( + Proxy( + Proxy.Type.HTTP, + InetSocketAddress("127.0.0.1", 4095) + ) + ) + .sslSocketFactory(sslContext.socketFactory, combinedTm) + .build() + else + OkHttpClient.Builder() + .connectionSpecs( + listOf( + ConnectionSpec.MODERN_TLS, + ConnectionSpec.COMPATIBLE_TLS + ) + ) + .sslSocketFactory(sslContext.socketFactory, combinedTm) + .build() } catch (e: Exception) { throw RuntimeException("Failed to create OkHttpClient with dynamic certificate", e) @@ -124,11 +159,24 @@ object ApiClient { fun createOkHttp(): OkHttpClient { return if (cert == "") - OkHttpClient - .Builder() - .connectionSpecs(listOf(ConnectionSpec.MODERN_TLS, ConnectionSpec.COMPATIBLE_TLS)) - .eventListener(dnsEventListener) - .build() + if (base.startsWith("abyss://")) + OkHttpClient + .Builder() + .proxy( + Proxy( + Proxy.Type.HTTP, + InetSocketAddress("::1", 4095) + ) + ) + .connectionSpecs(listOf(ConnectionSpec.MODERN_TLS, ConnectionSpec.COMPATIBLE_TLS)) + .eventListener(dnsEventListener) + .build() + else + OkHttpClient + .Builder() + .connectionSpecs(listOf(ConnectionSpec.MODERN_TLS, ConnectionSpec.COMPATIBLE_TLS)) + .eventListener(dnsEventListener) + .build() else createOkHttpClientWithDynamicCert(loadCertificateFromString(cert)) @@ -136,9 +184,10 @@ object ApiClient { private fun createRetrofit(): Retrofit { val okHttpClient = createOkHttp() + val b = replaceAbyssProtocol(base) return Retrofit.Builder() - .baseUrl(base) + .baseUrl(b) .client(okHttpClient) .addConverterFactory(GsonConverterFactory.create()) .addConverterFactory(json.asConverterFactory("application/json".toMediaType())) @@ -148,13 +197,13 @@ object ApiClient { var api: ApiInterface? = null - suspend fun apply(urls: String, crt: String): String? { + suspend fun apply(context: Context, urls: String, crt: String): String? { try { val urlList = urls.split(";").map { it.trim() } var selectedUrl: String? = null for (url in urlList) { - val host = url.toHttpUrlOrNull()?.host + val host = url.toUri().host if (host != null && pingHost(host)) { selectedUrl = url break @@ -168,6 +217,12 @@ object ApiClient { domain = selectedUrl.toHttpUrlOrNull()?.host ?: "" cert = crt base = selectedUrl + + withContext(Dispatchers.IO) + { + (context as AetherApp).abyssService?.proxy?.config(base.toUri().host!!, 4096) + } + api = createRetrofit().create(ApiInterface::class.java) return base } catch (e: Exception) { diff --git a/app/src/main/java/com/acitelight/aether/viewModel/HomeScreenViewModel.kt b/app/src/main/java/com/acitelight/aether/viewModel/HomeScreenViewModel.kt index 602b2d5..8739a96 100644 --- a/app/src/main/java/com/acitelight/aether/viewModel/HomeScreenViewModel.kt +++ b/app/src/main/java/com/acitelight/aether/viewModel/HomeScreenViewModel.kt @@ -1,6 +1,7 @@ package com.acitelight.aether.viewModel import android.app.Application +import android.content.Context import android.widget.Toast import androidx.compose.runtime.Composable import androidx.compose.runtime.mutableStateOf @@ -34,11 +35,13 @@ import kotlinx.coroutines.launch import javax.inject.Inject import com.acitelight.aether.service.* import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext @HiltViewModel class HomeScreenViewModel @Inject constructor( - private val settingsDataStoreManager: SettingsDataStoreManager + private val settingsDataStoreManager: SettingsDataStoreManager, + @ApplicationContext private val context: Context ) : ViewModel() { var _init = false @@ -73,7 +76,7 @@ class HomeScreenViewModel @Inject constructor( if(u=="" || p=="" || ur=="") return@launch try{ - val usedUrl = ApiClient.apply(ur, if(uss.first()) c else "") + val usedUrl = ApiClient.apply(context, ur, if(uss.first()) c else "") if (MediaManager.token == "null") MediaManager.token = AuthManager.fetchToken( diff --git a/app/src/main/java/com/acitelight/aether/viewModel/MeScreenViewModel.kt b/app/src/main/java/com/acitelight/aether/viewModel/MeScreenViewModel.kt index b76098e..6d82d4d 100644 --- a/app/src/main/java/com/acitelight/aether/viewModel/MeScreenViewModel.kt +++ b/app/src/main/java/com/acitelight/aether/viewModel/MeScreenViewModel.kt @@ -24,10 +24,12 @@ import kotlinx.coroutines.launch import javax.inject.Inject import com.acitelight.aether.service.* import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext @HiltViewModel class MeScreenViewModel @Inject constructor( - private val settingsDataStoreManager: SettingsDataStoreManager + private val settingsDataStoreManager: SettingsDataStoreManager, + @ApplicationContext private val context: Context ) : ViewModel() { val username = mutableStateOf(""); @@ -66,7 +68,7 @@ class MeScreenViewModel @Inject constructor( if (u == "" || p == "" || us == "") return@launch try { - val usedUrl = ApiClient.apply(u, if(uss.first()) c else "") + val usedUrl = ApiClient.apply(context, u, if(uss.first()) c else "") MediaManager.token = AuthManager.fetchToken( us, p