3 Commits

Author SHA1 Message Date
acite
01246e89ba [optimize] Optimize HomeScreen 2025-08-26 02:26:20 +08:00
acite
b74ca98bf9 [feat] Server Configure& Https 2025-08-26 02:10:35 +08:00
acite
ded0386419 [merge] Merge branch 'dev-bug2' 2025-08-25 19:10:37 +08:00
8 changed files with 303 additions and 91 deletions

View File

@@ -1,25 +1,101 @@
package com.acitelight.aether.service
import android.content.Context
import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory
import kotlinx.serialization.json.Json
import okhttp3.CertificatePinner
import okhttp3.HttpUrl
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.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 {
const val base: String = "http://192.168.1.213/"
var base: String = ""
var domain: String = ""
var cert: String = ""
private val json = Json {
ignoreUnknownKeys = true
}
private val retrofit = Retrofit.Builder()
.baseUrl(base)
.addConverterFactory(GsonConverterFactory.create())
.addConverterFactory(json.asConverterFactory("application/json".toMediaType()))
.build()
fun loadCertificateFromString(pemString: String): X509Certificate {
val certificateFactory = CertificateFactory.getInstance("X.509")
val decodedPem = pemString
.replace("-----BEGIN CERTIFICATE-----", "")
.replace("-----END CERTIFICATE-----", "")
.replace("\\s+".toRegex(), "")
val api: ApiInterface by lazy {
retrofit.create(ApiInterface::class.java)
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)
.client(okHttpClient)
.addConverterFactory(GsonConverterFactory.create())
.addConverterFactory(json.asConverterFactory("application/json".toMediaType()))
.build()
}
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
object AuthManager {
suspend fun fetchToken(baseUrl: String, username: String, privateKey: String): String? {
suspend fun fetchToken(username: String, privateKey: String): String? {
val api = ApiClient.api
var challengeBase64 = ""
try{
challengeBase64 = api.getChallenge(username).string()
challengeBase64 = api!!.getChallenge(username).string()
}catch (e: Exception)
{
print(e.message)
@@ -23,7 +23,7 @@ object AuthManager {
val signedBase64 = signChallenge(db64(privateKey), db64(challengeBase64))
return try {
api.verifyChallenge(username, ChallengeResponse(response = signedBase64)).string()
api!!.verifyChallenge(username, ChallengeResponse(response = signedBase64)).string()
} catch (e: Exception) {
e.printStackTrace()
null

View File

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

View File

@@ -1,6 +1,7 @@
package com.acitelight.aether.service
import android.content.Context
import androidx.compose.runtime.mutableStateListOf
import com.acitelight.aether.model.Video
import com.acitelight.aether.model.VideoQueryIndex
import kotlinx.coroutines.Dispatchers
@@ -50,11 +51,15 @@ object RecentManager
try{
val r = Json.decodeFromString<List<VideoQueryIndex>>(content)
val vn = r.map{
MediaManager.queryVideo(it.klass, it.id)
}.filter { it != null }
recent.clear()
for(it in r)
{
val v = MediaManager.queryVideo(it.klass, it.id)
if(v != null)
recent.add(recent.size, v)
}
_recent.value = vn.map { it!! }
return r
}catch (e: Exception)
{
@@ -74,6 +79,7 @@ object RecentManager
{
val temp = o[0]
val index = o.indexOf(video)
recent.removeAt(index)
o[0] = o[index]
o[index] = temp
}
@@ -85,15 +91,10 @@ object RecentManager
if(o.size >= 21)
o.removeAt(o.size - 1)
val vn = o.map{
MediaManager.queryVideo(it.klass, it.id)
}.filter { it != null }
_recent.value = vn.map { it!! }
recent.add(0, MediaManager.queryVideo(video.klass, video.id)!!)
writeFile(context, "recent.json", Json.encodeToString(o))
}
}
private val _recent = MutableStateFlow<List<Video>>(emptyList())
val recent: StateFlow<List<Video>> = _recent
val recent = mutableStateListOf<Video>()
}

View File

@@ -34,7 +34,6 @@ fun HomeScreen(homeScreenViewModel: HomeScreenViewModel = viewModel(), navContro
{
if(Global.loggedIn)
homeScreenViewModel.Init()
val recent by RecentManager.recent.collectAsState()
LazyColumn(modifier = Modifier.fillMaxWidth())
{
@@ -49,14 +48,14 @@ fun HomeScreen(homeScreenViewModel: HomeScreenViewModel = viewModel(), navContro
HorizontalDivider(Modifier.padding(8.dp), 2.dp, DividerDefaults.color)
for(i in recent)
for(i in RecentManager.recent)
{
MiniVideoCard(
modifier = Modifier
.padding(horizontal = 12.dp),
i,
{
Global.sameClassVideos = recent
Global.sameClassVideos = RecentManager.recent
val route = "video_player_route/${ "${i.klass}/${i.id}".toHex() }"
navController.navigate(route)
})

View File

@@ -8,10 +8,13 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
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.Security
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.Icon
@@ -25,19 +28,22 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.acitelight.aether.viewModel.MeScreenViewModel
@Composable
fun MeScreen(meScreenViewModel: MeScreenViewModel = viewModel())
{
fun MeScreen(meScreenViewModel: MeScreenViewModel = viewModel()) {
val context = LocalContext.current
var username by meScreenViewModel.username;
var privateKey by meScreenViewModel.privateKey;
var url by meScreenViewModel.url
var cert by meScreenViewModel.cert
Column(
LazyColumn(
modifier = Modifier
.fillMaxSize()
.padding(8.dp),
@@ -45,62 +51,130 @@ fun MeScreen(meScreenViewModel: MeScreenViewModel = viewModel())
verticalArrangement = Arrangement.Top
) {
// Card component for a clean, contained UI block
Card(
modifier = Modifier
.fillMaxWidth()
.padding(8.dp)
) {
Column(
item{
Card(
modifier = Modifier
.padding(16.dp)
.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
text = "Account Setting",
style = MaterialTheme.typography.headlineMedium,
modifier = Modifier.padding(bottom = 16.dp).align(Alignment.Start)
)
// Username input field
OutlinedTextField(
value = username,
onValueChange = { username = it },
label = { Text("Username") },
leadingIcon = {
Icon(Icons.Default.Person, contentDescription = "Username")
},
singleLine = true,
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(16.dp))
// Private key input field
OutlinedTextField(
value = privateKey,
onValueChange = { privateKey = it },
label = { Text("Key") },
leadingIcon = {
Icon(Icons.Default.Key, contentDescription = "Key")
},
singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(24.dp))
// Save Button
Button(
onClick = {
meScreenViewModel.updateAccount(username, privateKey, context)
},
modifier = Modifier.fillMaxWidth()
.fillMaxWidth()
.padding(8.dp)
)
{
Column(
modifier = Modifier
.padding(16.dp)
.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally
) {
Text("Save")
Text(
text = "Account Setting",
style = MaterialTheme.typography.headlineMedium,
modifier = Modifier
.padding(bottom = 16.dp)
.align(Alignment.Start)
)
// Username input field
OutlinedTextField(
value = username,
onValueChange = { username = it },
label = { Text("Username") },
leadingIcon = {
Icon(Icons.Default.Person, contentDescription = "Username")
},
singleLine = true,
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(16.dp))
// Private key input field
OutlinedTextField(
value = privateKey,
onValueChange = { privateKey = it },
label = { Text("Key") },
leadingIcon = {
Icon(Icons.Default.Key, contentDescription = "Key")
},
singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(24.dp))
// Save Button
Button(
onClick = {
meScreenViewModel.updateAccount(username, privateKey, context)
},
modifier = Modifier.fillMaxWidth()
) {
Text("Save")
}
}
}
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 USER_NAME_KEY = stringPreferencesKey("user_name")
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 ->
preferences[USER_NAME_KEY] ?: ""
@@ -41,6 +43,14 @@ class HomeScreenViewModel(application: Application) : AndroidViewModel(applicati
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
@Composable
@@ -60,13 +70,16 @@ class HomeScreenViewModel(application: Application) : AndroidViewModel(applicati
viewModelScope.launch {
val u = userNameFlow.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{
ApiClient.apply(ur, c)
if (MediaManager.token == "null")
MediaManager.token = AuthManager.fetchToken(
ApiClient.base,
u,
p
)!!
@@ -74,6 +87,7 @@ class HomeScreenViewModel(application: Application) : AndroidViewModel(applicati
Global.loggedIn = true
}catch(e: Exception)
{
Global.loggedIn = false
print(e.message)
}
}

View File

@@ -24,23 +24,70 @@ import kotlinx.coroutines.launch
class MeScreenViewModel(application: Application) : AndroidViewModel(application) {
private val dataStore = application.dataStore
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 ->
preferences[USER_NAME_KEY] ?: ""
}
val privateKeyFlow: Flow<String> = dataStore.data.map { preferences ->
val privateKeyFlow: Flow<String> = dataStore.data.map { preferences ->
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 privateKey = mutableStateOf("")
val url = mutableStateOf("");
val cert = mutableStateOf("")
init {
viewModelScope.launch {
username.value = userNameFlow.first()
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 {
MediaManager.token = AuthManager.fetchToken(
ApiClient.base,
u,
p
)!!