[feat] Server Configure& Https

This commit is contained in:
acite
2025-08-26 02:10:35 +08:00
parent ded0386419
commit b74ca98bf9
6 changed files with 289 additions and 77 deletions

View File

@@ -1,25 +1,101 @@
package com.acitelight.aether.service package com.acitelight.aether.service
import android.content.Context
import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import okhttp3.CertificatePinner
import okhttp3.HttpUrl
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import okhttp3.MediaType.Companion.toMediaType import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import retrofit2.Retrofit import retrofit2.Retrofit
import retrofit2.converter.gson.GsonConverterFactory import retrofit2.converter.gson.GsonConverterFactory
import java.io.ByteArrayInputStream
import java.security.KeyStore
import java.security.cert.Certificate
import java.security.cert.CertificateFactory
import java.security.cert.X509Certificate
import javax.net.ssl.SSLContext
import javax.net.ssl.TrustManagerFactory
import javax.net.ssl.X509TrustManager
object ApiClient { object ApiClient {
const val base: String = "http://192.168.1.213/" var base: String = ""
var domain: String = ""
var cert: String = ""
private val json = Json { private val json = Json {
ignoreUnknownKeys = true ignoreUnknownKeys = true
} }
private val retrofit = Retrofit.Builder() fun loadCertificateFromString(pemString: String): X509Certificate {
val certificateFactory = CertificateFactory.getInstance("X.509")
val decodedPem = pemString
.replace("-----BEGIN CERTIFICATE-----", "")
.replace("-----END CERTIFICATE-----", "")
.replace("\\s+".toRegex(), "")
val decodedBytes = android.util.Base64.decode(decodedPem, android.util.Base64.DEFAULT)
ByteArrayInputStream(decodedBytes).use { inputStream ->
return certificateFactory.generateCertificate(inputStream) as X509Certificate
}
}
fun createOkHttpClientWithDynamicCert(trustedCert: X509Certificate): OkHttpClient {
try {
val keyStore = KeyStore.getInstance(KeyStore.getDefaultType()).apply {
load(null, null)
setCertificateEntry("ca", trustedCert)
}
val tmfAlgorithm = TrustManagerFactory.getDefaultAlgorithm()
val tmf = TrustManagerFactory.getInstance(tmfAlgorithm).apply {
init(keyStore)
}
val trustManager = tmf.trustManagers.first { it is X509TrustManager } as X509TrustManager
val sslContext = SSLContext.getInstance("TLS").apply {
init(null, arrayOf(trustManager), null)
}
return OkHttpClient.Builder()
.sslSocketFactory(sslContext.socketFactory, trustManager)
.build()
} catch (e: Exception) {
throw RuntimeException("Failed to create OkHttpClient with dynamic certificate", e)
}
}
private fun createRetrofit(): Retrofit {
val okHttpClient = createOkHttpClientWithDynamicCert(loadCertificateFromString(cert))
return Retrofit.Builder()
.baseUrl(base) .baseUrl(base)
.client(okHttpClient)
.addConverterFactory(GsonConverterFactory.create()) .addConverterFactory(GsonConverterFactory.create())
.addConverterFactory(json.asConverterFactory("application/json".toMediaType())) .addConverterFactory(json.asConverterFactory("application/json".toMediaType()))
.build() .build()
}
val api: ApiInterface by lazy {
retrofit.create(ApiInterface::class.java) var api: ApiInterface? = null
fun apply(url: String, crt: String)
{
try{
domain = url.toHttpUrlOrNull()?.host !!
cert = crt
base = url
api = createRetrofit().create(ApiInterface::class.java)
}catch (e: Exception)
{
api = null
base = ""
domain = ""
cert = ""
}
} }
} }

View File

@@ -9,12 +9,12 @@ import java.security.PrivateKey
import java.security.Signature import java.security.Signature
object AuthManager { object AuthManager {
suspend fun fetchToken(baseUrl: String, username: String, privateKey: String): String? { suspend fun fetchToken(username: String, privateKey: String): String? {
val api = ApiClient.api val api = ApiClient.api
var challengeBase64 = "" var challengeBase64 = ""
try{ try{
challengeBase64 = api.getChallenge(username).string() challengeBase64 = api!!.getChallenge(username).string()
}catch (e: Exception) }catch (e: Exception)
{ {
print(e.message) print(e.message)
@@ -23,7 +23,7 @@ object AuthManager {
val signedBase64 = signChallenge(db64(privateKey), db64(challengeBase64)) val signedBase64 = signChallenge(db64(privateKey), db64(challengeBase64))
return try { return try {
api.verifyChallenge(username, ChallengeResponse(response = signedBase64)).string() api!!.verifyChallenge(username, ChallengeResponse(response = signedBase64)).string()
} catch (e: Exception) { } catch (e: Exception) {
e.printStackTrace() e.printStackTrace()
null null

View File

@@ -15,7 +15,7 @@ object MediaManager
{ {
try try
{ {
val j = ApiClient.api.getVideoClasses(token) val j = ApiClient.api!!.getVideoClasses(token)
return j.toList() return j.toList()
}catch(e: Exception) }catch(e: Exception)
{ {
@@ -26,7 +26,7 @@ object MediaManager
suspend fun listVideos(klass: String): List<Video> suspend fun listVideos(klass: String): List<Video>
{ {
try { try {
val j = ApiClient.api.queryVideoClasses(klass, token) val j = ApiClient.api!!.queryVideoClasses(klass, token)
return j.map{ return j.map{
queryVideo(klass, it)!! queryVideo(klass, it)!!
}.toList() }.toList()
@@ -39,7 +39,7 @@ object MediaManager
suspend fun queryVideo(klass: String, id: String): Video? suspend fun queryVideo(klass: String, id: String): Video?
{ {
try { try {
val j = ApiClient.api.queryVideo(klass, id, token) val j = ApiClient.api!!.queryVideo(klass, id, token)
return Video(klass = klass, id = id, token=token, j) return Video(klass = klass, id = id, token=token, j)
}catch (e: Exception) }catch (e: Exception)
{ {
@@ -49,11 +49,13 @@ object MediaManager
suspend fun listComics() : List<String> suspend fun listComics() : List<String>
{ {
return ApiClient.api.getComicCollections() // TODO: try
return ApiClient.api!!.getComicCollections()
} }
suspend fun queryComicInfo(c: String) : Comic suspend fun queryComicInfo(c: String) : Comic
{ {
return ApiClient.api.queryComicInfo(c) // TODO: try
return ApiClient.api!!.queryComicInfo(c)
} }
} }

View File

@@ -8,10 +8,13 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Key import androidx.compose.material.icons.filled.Key
import androidx.compose.material.icons.filled.Link
import androidx.compose.material.icons.filled.Person import androidx.compose.material.icons.filled.Person
import androidx.compose.material.icons.filled.Security
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.Card import androidx.compose.material3.Card
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
@@ -25,19 +28,22 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import com.acitelight.aether.viewModel.MeScreenViewModel import com.acitelight.aether.viewModel.MeScreenViewModel
@Composable @Composable
fun MeScreen(meScreenViewModel: MeScreenViewModel = viewModel()) fun MeScreen(meScreenViewModel: MeScreenViewModel = viewModel()) {
{
val context = LocalContext.current val context = LocalContext.current
var username by meScreenViewModel.username; var username by meScreenViewModel.username;
var privateKey by meScreenViewModel.privateKey; var privateKey by meScreenViewModel.privateKey;
var url by meScreenViewModel.url
var cert by meScreenViewModel.cert
Column( LazyColumn(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.padding(8.dp), .padding(8.dp),
@@ -45,11 +51,13 @@ fun MeScreen(meScreenViewModel: MeScreenViewModel = viewModel())
verticalArrangement = Arrangement.Top verticalArrangement = Arrangement.Top
) { ) {
// Card component for a clean, contained UI block // Card component for a clean, contained UI block
item{
Card( Card(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(8.dp) .padding(8.dp)
) { )
{
Column( Column(
modifier = Modifier modifier = Modifier
.padding(16.dp) .padding(16.dp)
@@ -59,7 +67,9 @@ fun MeScreen(meScreenViewModel: MeScreenViewModel = viewModel())
Text( Text(
text = "Account Setting", text = "Account Setting",
style = MaterialTheme.typography.headlineMedium, style = MaterialTheme.typography.headlineMedium,
modifier = Modifier.padding(bottom = 16.dp).align(Alignment.Start) modifier = Modifier
.padding(bottom = 16.dp)
.align(Alignment.Start)
) )
// Username input field // Username input field
@@ -102,5 +112,69 @@ fun MeScreen(meScreenViewModel: MeScreenViewModel = viewModel())
} }
} }
} }
Card(
modifier = Modifier
.fillMaxWidth()
.padding(8.dp)
)
{
Column(
modifier = Modifier
.padding(16.dp)
.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
text = "Server Setting",
style = MaterialTheme.typography.headlineMedium,
modifier = Modifier
.padding(bottom = 16.dp)
.align(Alignment.Start)
)
// Username input field
OutlinedTextField(
value = url,
onValueChange = { url = it },
label = { Text("Url") },
leadingIcon = {
Icon(Icons.Default.Link, contentDescription = "Url")
},
singleLine = true,
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(16.dp))
// Private key input field
OutlinedTextField(
value = cert,
onValueChange = { cert = it },
label = { Text("Cert") },
singleLine = false,
maxLines = 40,
minLines = 20,
modifier = Modifier.fillMaxWidth(),
textStyle = TextStyle(
fontSize = 8.sp
)
)
Spacer(modifier = Modifier.height(24.dp))
// Save Button
Button(
onClick = {
meScreenViewModel.updateServer(url, cert, context)
},
modifier = Modifier.fillMaxWidth()
) {
Text("Save")
}
}
}
}
} }
} }

View File

@@ -32,6 +32,8 @@ class HomeScreenViewModel(application: Application) : AndroidViewModel(applicati
private val dataStore = application.dataStore private val dataStore = application.dataStore
private val USER_NAME_KEY = stringPreferencesKey("user_name") private val USER_NAME_KEY = stringPreferencesKey("user_name")
private val PRIVATE_KEY = stringPreferencesKey("private_key") private val PRIVATE_KEY = stringPreferencesKey("private_key")
private val URL_KEY = stringPreferencesKey("url")
private val CERT_KEY = stringPreferencesKey("cert")
val userNameFlow: Flow<String> = dataStore.data.map { preferences -> val userNameFlow: Flow<String> = dataStore.data.map { preferences ->
preferences[USER_NAME_KEY] ?: "" preferences[USER_NAME_KEY] ?: ""
@@ -41,6 +43,14 @@ class HomeScreenViewModel(application: Application) : AndroidViewModel(applicati
preferences[PRIVATE_KEY] ?: "" preferences[PRIVATE_KEY] ?: ""
} }
val urlFlow: Flow<String> = dataStore.data.map { preferences ->
preferences[URL_KEY] ?: ""
}
val certFlow: Flow<String> = dataStore.data.map { preferences ->
preferences[CERT_KEY] ?: ""
}
var _init = false var _init = false
@Composable @Composable
@@ -60,13 +70,16 @@ class HomeScreenViewModel(application: Application) : AndroidViewModel(applicati
viewModelScope.launch { viewModelScope.launch {
val u = userNameFlow.first() val u = userNameFlow.first()
val p = privateKeyFlow.first() val p = privateKeyFlow.first()
val ur = urlFlow.first()
val c = certFlow.first()
if(u=="" || p=="") return@launch if(u=="" || p=="" || ur=="" || c=="") return@launch
try{ try{
ApiClient.apply(ur, c)
if (MediaManager.token == "null") if (MediaManager.token == "null")
MediaManager.token = AuthManager.fetchToken( MediaManager.token = AuthManager.fetchToken(
ApiClient.base,
u, u,
p p
)!! )!!
@@ -74,6 +87,7 @@ class HomeScreenViewModel(application: Application) : AndroidViewModel(applicati
Global.loggedIn = true Global.loggedIn = true
}catch(e: Exception) }catch(e: Exception)
{ {
Global.loggedIn = false
print(e.message) print(e.message)
} }
} }

View File

@@ -25,6 +25,8 @@ class MeScreenViewModel(application: Application) : AndroidViewModel(application
private val dataStore = application.dataStore private val dataStore = application.dataStore
private val USER_NAME_KEY = stringPreferencesKey("user_name") private val USER_NAME_KEY = stringPreferencesKey("user_name")
private val PRIVATE_KEY = stringPreferencesKey("private_key") private val PRIVATE_KEY = stringPreferencesKey("private_key")
private val URL_KEY = stringPreferencesKey("url")
private val CERT_KEY = stringPreferencesKey("cert")
val userNameFlow: Flow<String> = dataStore.data.map { preferences -> val userNameFlow: Flow<String> = dataStore.data.map { preferences ->
preferences[USER_NAME_KEY] ?: "" preferences[USER_NAME_KEY] ?: ""
@@ -34,13 +36,58 @@ class MeScreenViewModel(application: Application) : AndroidViewModel(application
preferences[PRIVATE_KEY] ?: "" preferences[PRIVATE_KEY] ?: ""
} }
val urlFlow: Flow<String> = dataStore.data.map { preferences ->
preferences[URL_KEY] ?: ""
}
val certFlow: Flow<String> = dataStore.data.map { preferences ->
preferences[CERT_KEY] ?: ""
}
val username = mutableStateOf(""); val username = mutableStateOf("");
val privateKey = mutableStateOf("") val privateKey = mutableStateOf("")
val url = mutableStateOf("");
val cert = mutableStateOf("")
init { init {
viewModelScope.launch { viewModelScope.launch {
username.value = userNameFlow.first() username.value = userNameFlow.first()
privateKey.value = if (privateKeyFlow.first() == "") "" else "******" privateKey.value = if (privateKeyFlow.first() == "") "" else "******"
url.value = urlFlow.first()
cert.value = certFlow.first()
}
}
fun updateServer(u: String, c: String, context: Context)
{
viewModelScope.launch {
dataStore.edit { preferences ->
preferences[URL_KEY] = u
preferences[CERT_KEY] = c
}
Global.loggedIn = false
val us = userNameFlow.first()
val u = urlFlow.first()
val c = certFlow.first()
val p = privateKeyFlow.first()
if (u == "" || c == "" || p == "") return@launch
try {
ApiClient.apply(u, c)
MediaManager.token = AuthManager.fetchToken(
us,
p
)!!
Global.loggedIn = true
Toast.makeText(context, "Server Updated", Toast.LENGTH_SHORT).show()
} catch (e: Exception) {
print(e.message)
Toast.makeText(context, "Invalid Account or Server Information", Toast.LENGTH_SHORT).show()
}
} }
} }
@@ -62,7 +109,6 @@ class MeScreenViewModel(application: Application) : AndroidViewModel(application
try { try {
MediaManager.token = AuthManager.fetchToken( MediaManager.token = AuthManager.fetchToken(
ApiClient.base,
u, u,
p p
)!! )!!