завел коммы

This commit is contained in:
Lain Iwakura 2025-08-09 18:12:54 +03:00
parent 7a5d6bbaf8
commit 875aab1185
No known key found for this signature in database
GPG Key ID: C7C18257F2ADC6F8
10 changed files with 142 additions and 62 deletions

View File

@ -16,6 +16,9 @@ import retrofit2.Retrofit
import retrofit2.converter.scalars.ScalarsConverterFactory
import java.io.File
import java.util.concurrent.TimeUnit
import okhttp3.FormBody
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
class ApiClient(context: Context) {
private val baseUrl = "https://mkch.pooziqo.xyz"
@ -24,6 +27,7 @@ class ApiClient(context: Context) {
private val json = Json { ignoreUnknownKeys = true }
private val prefs = context.getSharedPreferences("api_prefs", Context.MODE_PRIVATE)
private val cache = Cache(context)
private val userAgentInterceptor = Interceptor { chain ->
val request = chain.request().newBuilder()
@ -61,63 +65,78 @@ class ApiClient(context: Context) {
}
}
fun getBoards(): List<Board> = parseBoards(get("${apiUrl}boards/"))
fun getThreads(boardCode: String): List<ThreadItem> = parseThreads(get("${apiUrl}board/$boardCode"))
fun getThreadDetail(boardCode: String, threadId: Int): ThreadDetail =
parseThreadDetail(get("${apiUrl}board/$boardCode/thread/$threadId"))
fun getComments(boardCode: String, threadId: Int): List<Comment> =
parseComments(get("${apiUrl}board/$boardCode/thread/$threadId/comments"))
fun getFullThread(boardCode: String, threadId: Int): Pair<ThreadDetail, List<Comment>> {
val detail = getThreadDetail(boardCode, threadId)
val comments = getComments(boardCode, threadId)
return detail to comments
suspend fun getBoards(): List<Board> = withContext(Dispatchers.IO) {
parseBoards(get("${apiUrl}boards/"))
}
fun authenticate(authKey: String) {
if (authKey.isEmpty()) return
suspend fun getThreads(boardCode: String): List<ThreadItem> = withContext(Dispatchers.IO) {
parseThreads(get("${apiUrl}board/$boardCode"))
}
suspend fun getThreadDetail(boardCode: String, threadId: Int): ThreadDetail = withContext(Dispatchers.IO) {
parseThreadDetail(get("${apiUrl}board/$boardCode/thread/$threadId"))
}
suspend fun getComments(boardCode: String, threadId: Int): List<Comment> = withContext(Dispatchers.IO) {
parseComments(get("${apiUrl}board/$boardCode/thread/$threadId/comments"))
}
suspend fun getFullThread(boardCode: String, threadId: Int): Pair<ThreadDetail, List<Comment>> = withContext(Dispatchers.IO) {
val detail = getThreadDetail(boardCode, threadId)
val comments = getComments(boardCode, threadId)
detail to comments
}
suspend fun authenticate(authKey: String) = withContext(Dispatchers.IO) {
if (authKey.isEmpty()) return@withContext
val url = "$baseUrl/key/auth/"
val formHtml = get(url)
val csrf = extractCsrf(formHtml)
val body = "csrfmiddlewaretoken=${'$'}csrf&key=${'$'}authKey"
.toRequestBody("application/x-www-form-urlencoded".toMediaType())
val body = FormBody.Builder()
.add("csrfmiddlewaretoken", csrf)
.add("key", authKey)
.build()
val req = Request.Builder().url(url)
.post(body)
.header("Referer", url)
.build()
httpClient.newCall(req).execute().use { resp ->
if (!(resp.code == 200 || resp.code == 302)) throw RuntimeException("auth failed ${'$'}{resp.code}")
if (!(resp.code == 200 || resp.code == 302)) throw RuntimeException("Auth failed: ${'$'}{resp.code}")
}
}
fun loginWithPasscode(passcode: String) {
if (passcode.isEmpty()) return
suspend fun loginWithPasscode(passcode: String) = withContext(Dispatchers.IO) {
if (passcode.isEmpty()) return@withContext
val url = "$baseUrl/passcode/enter/"
val formHtml = get(url)
val csrf = extractCsrf(formHtml)
val body = "csrfmiddlewaretoken=${'$'}csrf&passcode=${'$'}passcode"
.toRequestBody("application/x-www-form-urlencoded".toMediaType())
val body = FormBody.Builder()
.add("csrfmiddlewaretoken", csrf)
.add("passcode", passcode)
.build()
val req = Request.Builder().url(url)
.post(body)
.header("Referer", url)
.build()
httpClient.newCall(req).execute().use { resp ->
if (!(resp.code == 200 || resp.code == 302)) throw RuntimeException("passcode failed ${'$'}{resp.code}")
if (!(resp.code == 200 || resp.code == 302)) throw RuntimeException("Passcode failed: ${'$'}{resp.code}")
}
}
fun createThread(boardCode: String, title: String, text: String, files: List<File> = emptyList(), passcode: String = "", authKey: String = "") {
suspend fun createThread(boardCode: String, title: String, text: String, files: List<File> = emptyList(), passcode: String = "", authKey: String = "") = withContext(Dispatchers.IO) {
if (authKey.isNotEmpty()) authenticate(authKey)
if (passcode.isNotEmpty()) loginWithPasscode(passcode)
val formUrl = "$baseUrl/boards/board/$boardCode/new"
val csrf = fetchCsrf(formUrl)
val req = if (files.isEmpty()) {
val body = "csrfmiddlewaretoken=$csrf&title=$title&text=$text"
.toRequestBody("application/x-www-form-urlencoded".toMediaType())
val body = FormBody.Builder()
.add("csrfmiddlewaretoken", csrf)
.add("title", title)
.add("text", text)
.build()
Request.Builder().url(formUrl).post(body)
.header("Referer", formUrl)
.build()
} else {
val builder = MultipartBody.Builder().setType(MultipartBody.FORM)
.addFormDataPart("csrfmiddlewaretoken", csrf)
@ -128,23 +147,28 @@ class ApiClient(context: Context) {
builder.addFormDataPart("files", "photo_${idx + 1}.jpg", rb)
}
Request.Builder().url(formUrl).post(builder.build())
}
.header("Referer", formUrl)
.build()
}
httpClient.newCall(req).execute().use { resp ->
if (!(resp.code == 200 || resp.code == 302)) throw RuntimeException("createThread failed ${resp.code}")
cache.delete("threads_${boardCode}")
}
}
fun addComment(boardCode: String, threadId: Int, text: String, files: List<File> = emptyList(), passcode: String = "", authKey: String = "") {
suspend fun addComment(boardCode: String, threadId: Int, text: String, files: List<File> = emptyList(), passcode: String = "", authKey: String = "") = withContext(Dispatchers.IO) {
if (authKey.isNotEmpty()) authenticate(authKey)
if (passcode.isNotEmpty()) loginWithPasscode(passcode)
val formUrl = "$baseUrl/boards/board/$boardCode/thread/$threadId/comment"
val csrf = fetchCsrf(formUrl)
val req = if (files.isEmpty()) {
val body = "csrfmiddlewaretoken=$csrf&text=$text"
.toRequestBody("application/x-www-form-urlencoded".toMediaType())
val body = FormBody.Builder()
.add("csrfmiddlewaretoken", csrf)
.add("text", text)
.build()
Request.Builder().url(formUrl).post(body)
.header("Referer", formUrl)
.build()
} else {
val builder = MultipartBody.Builder().setType(MultipartBody.FORM)
.addFormDataPart("csrfmiddlewaretoken", csrf)
@ -154,11 +178,12 @@ class ApiClient(context: Context) {
builder.addFormDataPart("files", "photo_${idx + 1}.jpg", rb)
}
Request.Builder().url(formUrl).post(builder.build())
}
.header("Referer", formUrl)
.build()
}
httpClient.newCall(req).execute().use { resp ->
if (!(resp.code == 200 || resp.code == 302)) throw RuntimeException("addComment failed ${resp.code}")
cache.delete("comments_${threadId}")
}
}
@ -173,11 +198,11 @@ class ApiClient(context: Context) {
}
}
fun checkNewThreads(boardCode: String): List<ThreadItem> {
suspend fun checkNewThreads(boardCode: String): List<ThreadItem> = withContext(Dispatchers.IO) {
val current = getThreads(boardCode)
val key = "savedThreads_${'$'}boardCode"
val key = "savedThreads_${boardCode}"
val savedRaw = prefs.getString(key, null)
return if (savedRaw != null) {
return@withContext if (savedRaw != null) {
val saved = runCatching { json.decodeFromString<List<ThreadItem>>(savedRaw) }.getOrElse { emptyList() }
val savedIds = saved.map { it.id }.toSet()
val newOnes = current.filter { it.id !in savedIds }

View File

@ -39,6 +39,10 @@ class Cache(private val context: Context) {
File(dir, safeName(key)).delete()
}
suspend fun clearAll() = withContext(Dispatchers.IO) {
dir.listFiles()?.forEach { runCatching { it.delete() } }
}
private fun safeName(key: String) = key.hashCode().toString(16) + ".json"
}

View File

@ -11,16 +11,16 @@ class PersistentCookieJar(context: Context) : CookieJar {
override fun saveFromResponse(url: HttpUrl, cookies: List<Cookie>) {
val key = url.host
val existing = loadForRequest(url).toMutableList()
existing.removeAll { e -> cookies.any { it.name == e.name } }
existing.removeAll { e -> cookies.any { it.name == e.name && it.domain == e.domain && it.path == e.path } }
existing.addAll(cookies)
val serialized = existing.joinToString(";") { it.toString() }
val serialized = existing.joinToString("\n") { it.toString() }
prefs.edit().putString(key, serialized).apply()
}
override fun loadForRequest(url: HttpUrl): List<Cookie> {
val key = url.host
val stored = prefs.getString(key, null) ?: return emptyList()
return stored.split(";").mapNotNull { Cookie.parse(url, it) }
return stored.split('\n').mapNotNull { line -> Cookie.parse(url, line) }
}
}

View File

@ -105,11 +105,17 @@ fun AppRoot() {
composable("thread/{board}/{id}/comment") { backStack ->
val board = backStack.arguments?.getString("board").orEmpty()
val id = backStack.arguments?.getString("id")?.toIntOrNull() ?: 0
AddCommentScreen(boardCode = board, threadId = id)
AddCommentScreen(boardCode = board, threadId = id, onDone = {
navController.popBackStack()
})
}
composable("threads/{board}/new") { backStack ->
val board = backStack.arguments?.getString("board").orEmpty()
CreateThreadScreen(boardCode = board)
CreateThreadScreen(boardCode = board, onDone = {
navController.popBackStack()
navController.popBackStack()
navController.navigate("boards")
})
}
}
}

View File

@ -55,9 +55,12 @@ class AddCommentVm(app: Application) : AndroidViewModel(app) {
viewModelScope.launch {
val settings = SettingsStore(getApplication()).data.first()
val files = withContext(Dispatchers.IO) { uris.mapNotNull { uri -> ImageUtil.downscaleJpeg(context, uri) } }
runCatching { api.addComment(board, id, text, files, passcode = settings.passcode, authKey = settings.key) }
.onSuccess { onDone() }
.onFailure { error = it.message }
try {
api.addComment(board, id, text, files, passcode = settings.passcode, authKey = settings.key)
onDone()
} catch (t: Throwable) {
error = t.message
}
isLoading = false
withContext(Dispatchers.IO) { files.forEach { it.delete() } }
}
@ -67,7 +70,7 @@ class AddCommentVm(app: Application) : AndroidViewModel(app) {
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AddCommentScreen(boardCode: String, threadId: Int, vm: AddCommentVm = viewModel()) {
fun AddCommentScreen(boardCode: String, threadId: Int, onDone: () -> Unit = {}, vm: AddCommentVm = viewModel()) {
val context = androidx.compose.ui.platform.LocalContext.current
var text by remember { mutableStateOf("") }
var picked by remember { mutableStateOf<List<Uri>>(emptyList()) }
@ -89,7 +92,7 @@ fun AddCommentScreen(boardCode: String, threadId: Int, vm: AddCommentVm = viewMo
}
}
if (vm.error != null) Text(vm.error ?: "")
Button(onClick = { vm.send(context, boardCode, threadId, text, picked) {} }, enabled = text.isNotEmpty() && !vm.isLoading, modifier = Modifier.padding(top = 8.dp)) { Text(if (vm.isLoading) "Отправка..." else "Добавить") }
Button(onClick = { vm.send(context, boardCode, threadId, text, picked, onDone) }, enabled = text.isNotEmpty() && !vm.isLoading, modifier = Modifier.padding(top = 8.dp)) { Text(if (vm.isLoading) "Отправка..." else "Добавить") }
}
}
}

View File

@ -53,9 +53,12 @@ class CreateThreadVm(app: Application) : AndroidViewModel(app) {
viewModelScope.launch {
val settings = SettingsStore(getApplication()).data.first()
val files = withContext(Dispatchers.IO) { uris.mapNotNull { uri -> ImageUtil.downscaleJpeg(context, uri) } }
runCatching { api.createThread(board, title, text, files, passcode = settings.passcode, authKey = settings.key) }
.onSuccess { onDone() }
.onFailure { error = it.message }
try {
api.createThread(board, title, text, files, passcode = settings.passcode, authKey = settings.key)
onDone()
} catch (t: Throwable) {
error = t.message
}
isLoading = false
withContext(Dispatchers.IO) { files.forEach { it.delete() } }
}
@ -65,7 +68,7 @@ class CreateThreadVm(app: Application) : AndroidViewModel(app) {
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun CreateThreadScreen(boardCode: String, vm: CreateThreadVm = viewModel()) {
fun CreateThreadScreen(boardCode: String, onDone: () -> Unit = {}, vm: CreateThreadVm = viewModel()) {
val context = androidx.compose.ui.platform.LocalContext.current
var title by remember { mutableStateOf("") }
var text by remember { mutableStateOf("") }
@ -94,7 +97,7 @@ fun CreateThreadScreen(boardCode: String, vm: CreateThreadVm = viewModel()) {
}
}
if (vm.error != null) Text(vm.error ?: "")
Button(onClick = { vm.create(context, boardCode, title, text, picked) {} }, enabled = title.isNotEmpty() && text.isNotEmpty() && !vm.isLoading, modifier = Modifier.padding(top = 8.dp)) { Text(if (vm.isLoading) "Отправка..." else "Создать") }
Button(onClick = { vm.create(context, boardCode, title, text, picked, onDone) }, enabled = title.isNotEmpty() && text.isNotEmpty() && !vm.isLoading, modifier = Modifier.padding(top = 8.dp)) { Text(if (vm.isLoading) "Отправка..." else "Создать") }
}
}
}

View File

@ -16,6 +16,8 @@ import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
import androidx.compose.runtime.LaunchedEffect
import androidx.lifecycle.viewModelScope
@ -60,11 +62,19 @@ class SettingsVm(app: Application) : AndroidViewModel(app) {
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SettingsScreen(vm: SettingsVm = viewModel()) {
val scope = androidx.compose.runtime.rememberCoroutineScope()
val settings by vm.flow.collectAsState(initial = SettingsData())
val subs by vm.subsFlow.collectAsState(initial = emptySet())
LaunchedEffect(Unit) { vm.loadBoards() }
Scaffold(topBar = { TopAppBar(title = { Text("Настройки") }) }) { padding ->
Column(Modifier.fillMaxSize().padding(padding).padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
Column(
Modifier
.fillMaxSize()
.padding(padding)
.padding(16.dp)
.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
Row(Modifier.fillMaxWidth()) {
Text("Показывать файлы", modifier = Modifier.weight(1f))
Checkbox(checked = settings.showFiles, onCheckedChange = { vm.update { it.copy(showFiles = it.showFiles.not()) } })
@ -106,8 +116,8 @@ fun SettingsScreen(vm: SettingsVm = viewModel()) {
if (vm.boards.isEmpty()) {
Text("Загрузка...")
} else {
LazyColumn(verticalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.weight(1f)) {
items(vm.boards) { b ->
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
vm.boards.forEach { b ->
Row(Modifier.fillMaxWidth()) {
Text("/${'$'}{b.code}/ ${'$'}{b.description}", modifier = Modifier.weight(1f))
val on = subs.contains(b.code)
@ -141,7 +151,14 @@ fun SettingsScreen(vm: SettingsVm = viewModel()) {
Text("Принудительно оффлайн", modifier = Modifier.weight(1f))
Checkbox(checked = settings.offlineMode, onCheckedChange = { vm.update { it.copy(offlineMode = it.offlineMode.not()) } })
}
val ctx = androidx.compose.ui.platform.LocalContext.current
Row(Modifier.fillMaxWidth()) {
Button(onClick = {
scope.launch { com.mkfunproj.mobilemkch.data.Cache(ctx).clearAll() }
}) { Text("Очистить кэш API") }
Spacer(Modifier.weight(1f))
Button(onClick = { /* Coil кэш чистить можно при необходимости */ }) { Text("Очистить кэш изображ.") }
}
}
}
}

View File

@ -25,10 +25,15 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.compose.ui.platform.LocalLifecycleOwner
import com.mkfunproj.mobilemkch.data.Comment
import com.mkfunproj.mobilemkch.data.Repository
import com.mkfunproj.mobilemkch.data.ThreadDetail
@ -71,8 +76,18 @@ fun ThreadDetailScreen(
vm: ThreadDetailVm = viewModel(),
onAddComment: () -> Unit = {}
) {
val scope = androidx.compose.runtime.rememberCoroutineScope()
val scope = rememberCoroutineScope()
LaunchedEffect(threadId) { vm.load(boardCode, threadId) }
val lifecycleOwner = LocalLifecycleOwner.current
DisposableEffect(lifecycleOwner, boardCode, threadId) {
val observer = LifecycleEventObserver { _, event ->
if (event == Lifecycle.Event.ON_RESUME) {
scope.launch { vm.load(boardCode, threadId) }
}
}
lifecycleOwner.lifecycle.addObserver(observer)
onDispose { lifecycleOwner.lifecycle.removeObserver(observer) }
}
Scaffold(
topBar = { TopAppBar(title = { Text("#$threadId") }, actions = { androidx.compose.material3.TextButton(onClick = { scope.launch { vm.load(boardCode, threadId) } }) { Text("Обновить") } }) },
floatingActionButton = { FloatingActionButton(onClick = onAddComment) { Icon(Icons.Default.Add, contentDescription = "Add") } }

View File

@ -95,11 +95,18 @@ fun ThreadsScreen(
onCreateThread: (boardCode: String) -> Unit = {},
vm: ThreadsVm = viewModel()
) {
LaunchedEffect(boardCode) { vm.load(boardCode) }
val context = androidx.compose.ui.platform.LocalContext.current
LaunchedEffect(boardCode) {
vm.load(boardCode)
vm.viewModelScope.launch { com.mkfunproj.mobilemkch.data.SettingsStore(context).update { it.copy(lastBoard = boardCode) } }
}
var currentPage by androidx.compose.runtime.remember { mutableStateOf(0) }
val scope = androidx.compose.runtime.rememberCoroutineScope()
Scaffold(topBar = { TopAppBar(title = { Text("/$boardCode/ — $boardDescription") }, actions = {
androidx.compose.material3.TextButton(onClick = { scope.launch { vm.load(boardCode) } }) { Text("Обновить") }
androidx.compose.material3.TextButton(onClick = {
currentPage = 0
scope.launch { vm.load(boardCode) }
}) { Text("Обновить") }
androidx.compose.material3.TextButton(onClick = { onCreateThread(boardCode) }) { Text("Создать") }
}) }) { padding ->
when {
@ -189,7 +196,6 @@ private fun ThreadList(padding: PaddingValues, vm: ThreadsVm, boardCode: String,
if (vm.showFiles && t.files.isNotEmpty()) { Icon(Icons.Filled.AttachFile, contentDescription = null, tint = MaterialTheme.colorScheme.primary); Spacer(Modifier.width(4.dp)); Text("${t.files.size}", style = MaterialTheme.typography.labelSmall) }
}
if (t.text.isNotEmpty()) Text(t.text, style = MaterialTheme.typography.bodyMedium, maxLines = 3)
if (vm.showFiles && t.files.isNotEmpty()) { Spacer(Modifier.height(4.dp)); t.files.forEach { f -> AsyncImage(model = "https://mkch.pooziqo.xyz$f", contentDescription = null) } }
}
Divider(Modifier.padding(top = 8.dp))
}

View File

@ -18,12 +18,13 @@ class ThreadsCheckWorker(appContext: Context, workerParams: WorkerParameters) :
val api = ApiClient(applicationContext)
var notified = 0
subs.forEach { board ->
runCatching { api.checkNewThreads(board) }.onSuccess { list ->
try {
val list = api.checkNewThreads(board)
list.forEach { t ->
NotificationHelper.notifyThread(applicationContext, board, t.id, t.title)
notified += 1
}
}
} catch (_: Throwable) {}
}
return Result.success()
}