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