diff options
| author | JustZvan <justzvan@justzvan.xyz> | 2026-04-08 19:41:36 +0200 |
|---|---|---|
| committer | JustZvan <justzvan@justzvan.xyz> | 2026-04-08 19:41:36 +0200 |
| commit | 1f07f153b23fa9a7ae0ea648b498dad60f96c594 (patch) | |
| tree | c7c7d1c824d7fdfeaea4ca77ea22c370708ed8a7 /app/src/main/java | |
| parent | adb6a4fd9ec3a23c04d5e4c2ce799448237915c4 (diff) | |
feat: 1.2main
Diffstat (limited to 'app/src/main/java')
| -rw-r--r-- | app/src/main/java/sh/lajo/buddy/AppNavHost.kt | 13 | ||||
| -rw-r--r-- | app/src/main/java/sh/lajo/buddy/ConfigManager.kt | 5 | ||||
| -rw-r--r-- | app/src/main/java/sh/lajo/buddy/Destination.kt | 7 | ||||
| -rw-r--r-- | app/src/main/java/sh/lajo/buddy/DeviceConfig.kt | 3 | ||||
| -rw-r--r-- | app/src/main/java/sh/lajo/buddy/EducationModels.kt | 94 | ||||
| -rw-r--r-- | app/src/main/java/sh/lajo/buddy/EducationScreen.kt | 216 | ||||
| -rw-r--r-- | app/src/main/java/sh/lajo/buddy/ImagesObserver.kt | 218 | ||||
| -rw-r--r-- | app/src/main/java/sh/lajo/buddy/MainActivity.kt | 2 | ||||
| -rw-r--r-- | app/src/main/java/sh/lajo/buddy/OnboardingScreens.kt | 128 | ||||
| -rw-r--r-- | app/src/main/java/sh/lajo/buddy/WebSocketService.kt | 68 |
10 files changed, 732 insertions, 22 deletions
diff --git a/app/src/main/java/sh/lajo/buddy/AppNavHost.kt b/app/src/main/java/sh/lajo/buddy/AppNavHost.kt index d79195a..1fbdd17 100644 --- a/app/src/main/java/sh/lajo/buddy/AppNavHost.kt +++ b/app/src/main/java/sh/lajo/buddy/AppNavHost.kt @@ -12,9 +12,11 @@ private const val ROUTE_ONBOARDING_STEP3 = "onboarding/step3" private const val ROUTE_ONBOARDING_STEP4 = "onboarding/step4" private const val ROUTE_ONBOARDING_STEP5 = "onboarding/step5" private const val ROUTE_ONBOARDING_STEP6 = "onboarding/step6" +private const val ROUTE_ONBOARDING_MEDIA = "onboarding/media" private const val ROUTE_ONBOARDING_STEP7 = "onboarding/step7" private const val ROUTE_MAIN = "main" private const val ROUTE_HOME = "home" +private const val ROUTE_EDUCATION = "education" private const val ROUTE_SETTINGS = "settings" @Composable @@ -65,6 +67,13 @@ fun AppNavHost( composable(ROUTE_ONBOARDING_STEP6) { OnboardingStep6Screen( onBack = { navController.popBackStack() }, + onNext = { navController.navigate(ROUTE_ONBOARDING_MEDIA) }, + ) + } + + composable(ROUTE_ONBOARDING_MEDIA) { + OnboardingStepMediaScreen( + onBack = { navController.popBackStack() }, onNext = { navController.navigate(ROUTE_ONBOARDING_STEP7) }, ) } @@ -89,6 +98,10 @@ fun AppNavHost( HomeScreen() } + composable(ROUTE_EDUCATION) { + EducationScreen() + } + composable(ROUTE_SETTINGS) { SettingsScreen() } diff --git a/app/src/main/java/sh/lajo/buddy/ConfigManager.kt b/app/src/main/java/sh/lajo/buddy/ConfigManager.kt index eb37184..42a4f18 100644 --- a/app/src/main/java/sh/lajo/buddy/ConfigManager.kt +++ b/app/src/main/java/sh/lajo/buddy/ConfigManager.kt @@ -13,6 +13,7 @@ object ConfigManager { private const val TAG = "ConfigManager" private const val PREFS_KEY_DISABLE_BUDDY = "config_disable_buddy" private const val PREFS_KEY_BLOCK_ADULT_SITES = "config_block_adult_sites" + private const val PREFS_KEY_GALLERY_SCANNING_MODE = "config_gallery_scanning_mode" private const val PREFS_KEY_LAST_FETCH = "config_last_fetch" private const val FETCH_INTERVAL_MS = 5 * 60 * 1000L // 5 minutes @@ -22,7 +23,8 @@ object ConfigManager { val prefs = context.getSharedPreferences("app_prefs", Context.MODE_PRIVATE) return DeviceConfig( disableBuddy = prefs.getBoolean(PREFS_KEY_DISABLE_BUDDY, false), - blockAdultSites = prefs.getBoolean(PREFS_KEY_BLOCK_ADULT_SITES, true) + blockAdultSites = prefs.getBoolean(PREFS_KEY_BLOCK_ADULT_SITES, true), + galleryScanningMode = prefs.getString(PREFS_KEY_GALLERY_SCANNING_MODE, "none") ?: "none" ) } @@ -31,6 +33,7 @@ object ConfigManager { prefs.edit().apply { putBoolean(PREFS_KEY_DISABLE_BUDDY, config.disableBuddy) putBoolean(PREFS_KEY_BLOCK_ADULT_SITES, config.blockAdultSites) + putString(PREFS_KEY_GALLERY_SCANNING_MODE, config.galleryScanningMode) apply() } } diff --git a/app/src/main/java/sh/lajo/buddy/Destination.kt b/app/src/main/java/sh/lajo/buddy/Destination.kt index ba82cbb..b2881c4 100644 --- a/app/src/main/java/sh/lajo/buddy/Destination.kt +++ b/app/src/main/java/sh/lajo/buddy/Destination.kt @@ -3,6 +3,7 @@ package sh.lajo.buddy import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Home import androidx.compose.material.icons.filled.Settings +import androidx.compose.material.icons.filled.Info import androidx.compose.ui.graphics.vector.ImageVector enum class Destination( @@ -17,6 +18,12 @@ enum class Destination( icon = Icons.Default.Home, contentDescription = "Home Screen" ), + Education( + route = "education", + label = "Education", + icon = Icons.Default.Info, + contentDescription = "Educational Mode" + ), Settings( route = "settings", label = "Settings", diff --git a/app/src/main/java/sh/lajo/buddy/DeviceConfig.kt b/app/src/main/java/sh/lajo/buddy/DeviceConfig.kt index f8e24a9..66c9ac3 100644 --- a/app/src/main/java/sh/lajo/buddy/DeviceConfig.kt +++ b/app/src/main/java/sh/lajo/buddy/DeviceConfig.kt @@ -5,7 +5,8 @@ import kotlinx.serialization.Serializable @Serializable data class DeviceConfig( val disableBuddy: Boolean = false, - val blockAdultSites: Boolean = true + val blockAdultSites: Boolean = true, + val galleryScanningMode: String = "none" ) @Serializable diff --git a/app/src/main/java/sh/lajo/buddy/EducationModels.kt b/app/src/main/java/sh/lajo/buddy/EducationModels.kt new file mode 100644 index 0000000..57e4947 --- /dev/null +++ b/app/src/main/java/sh/lajo/buddy/EducationModels.kt @@ -0,0 +1,94 @@ +package sh.lajo.buddy + +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.jsonPrimitive +import okhttp3.Request +import java.io.IOException +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import kotlinx.serialization.KSerializer +import kotlinx.serialization.descriptors.PrimitiveKind +import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder +import kotlinx.serialization.json.JsonDecoder +import kotlinx.serialization.json.JsonPrimitive + +object FlexibleIdSerializer : KSerializer<String> { + override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("FlexibleId", PrimitiveKind.STRING) + override fun serialize(encoder: Encoder, value: String) = encoder.encodeString(value) + override fun deserialize(decoder: Decoder): String { + return if (decoder is JsonDecoder) { + val element = decoder.decodeJsonElement() + if (element is JsonPrimitive) { + element.content + } else { + element.toString() + } + } else { + decoder.decodeString() + } + } +} + +@Serializable +data class EducationLecture( + @Serializable(with = FlexibleIdSerializer::class) + val id: String, + val title: String, + val lectureItems: List<LectureItem>, + val quiz: Quiz +) + +@Serializable +data class LectureItem( + @Serializable(with = FlexibleIdSerializer::class) + val id: String, + val title: String, + val lectureElements: List<LectureElement> +) + +@Serializable +data class LectureElement( + val type: String, + val text: String? = null, + val imageUrl: String? = null +) + +@Serializable +data class Quiz( + val questions: List<Question> +) + +@Serializable +data class Question( + val question: String, + val answers: List<String>, + val correctAnswer: String +) + +class EducationRepository(private val baseUrl: String = ApiConfig.BASE_URL) { + private val json = Json { + ignoreUnknownKeys = true + isLenient = true + } + + suspend fun fetchLectures(): List<EducationLecture> = withContext(Dispatchers.IO) { + val request = Request.Builder() + .url("https://raw.githubusercontent.com/lajo-sh/assets/refs/heads/main/lectures-buddy.json") + .build() + + try { + HttpClient.client.newCall(request).execute().use { response -> + if (!response.isSuccessful) return@withContext emptyList() + val body = response.body?.string() ?: return@withContext emptyList() + json.decodeFromString<List<EducationLecture>>(body) + } + } catch (e: Exception) { + emptyList() + } + } +} diff --git a/app/src/main/java/sh/lajo/buddy/EducationScreen.kt b/app/src/main/java/sh/lajo/buddy/EducationScreen.kt new file mode 100644 index 0000000..e37770a --- /dev/null +++ b/app/src/main/java/sh/lajo/buddy/EducationScreen.kt @@ -0,0 +1,216 @@ +package sh.lajo.buddy + +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowBack +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import coil.compose.AsyncImage + +@Composable +fun EducationScreen(repository: EducationRepository = remember { EducationRepository() }) { + var lectures by remember { mutableStateOf<List<EducationLecture>>(emptyList()) } + var selectedLecture by remember { mutableStateOf<EducationLecture?>(null) } + var showingQuiz by remember { mutableStateOf(false) } + var isLoading by remember { mutableStateOf(true) } + + LaunchedEffect(Unit) { + lectures = repository.fetchLectures() + isLoading = false + } + + if (isLoading) { + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + CircularProgressIndicator() + } + } else if (showingQuiz && selectedLecture != null) { + QuizScreen(quiz = selectedLecture!!.quiz, onBack = { showingQuiz = false }) + } else if (selectedLecture != null) { + LectureDetailScreen( + lecture = selectedLecture!!, + onBack = { selectedLecture = null }, + onStartQuiz = { showingQuiz = true } + ) + } else { + LectureListScreen(lectures = lectures, onLectureClick = { selectedLecture = it }) + } +} + +@Composable +fun LectureListScreen(lectures: List<EducationLecture>, onLectureClick: (EducationLecture) -> Unit) { + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + item { + Text( + text = "Edukacija", + style = MaterialTheme.typography.headlineMedium, + modifier = Modifier.padding(bottom = 16.dp) + ) + } + items(lectures) { lecture -> + ElevatedCard( + onClick = { onLectureClick(lecture) }, + modifier = Modifier.fillMaxWidth() + ) { + Text( + text = lecture.title, + modifier = Modifier.padding(16.dp), + style = MaterialTheme.typography.titleLarge + ) + } + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun LectureDetailScreen( + lecture: EducationLecture, + onBack: () -> Unit, + onStartQuiz: () -> Unit +) { + Scaffold( + topBar = { + TopAppBar( + title = { Text(lecture.title) }, + navigationIcon = { + IconButton(onClick = onBack) { + Icon(Icons.Default.ArrowBack, contentDescription = "Back") + } + } + ) + } + ) { paddingValues -> + LazyColumn( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues), + contentPadding = PaddingValues(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + lecture.lectureItems.forEach { item -> + item { + Text(text = item.title, style = MaterialTheme.typography.headlineSmall) + } + items(item.lectureElements) { element -> + when (element.type) { + "text" -> element.text?.let { Text(text = it, style = MaterialTheme.typography.bodyLarge) } + "image" -> element.imageUrl?.let { + AsyncImage( + model = it, + contentDescription = null, + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 8.dp) + ) + } + } + } + item { + HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp)) + } + } + item { + Button( + onClick = onStartQuiz, + modifier = Modifier + .fillMaxWidth() + .padding(top = 16.dp) + ) { + Text("Započni kviz") + } + } + } + } +} + +@Composable +fun QuizScreen(quiz: Quiz, onBack: () -> Unit) { + var currentQuestionIndex by remember { mutableIntStateOf(0) } + var selectedAnswerIndex by remember { mutableStateOf<Int?>(null) } + var score by remember { mutableIntStateOf(0) } + var showResult by remember { mutableStateOf(false) } + + val currentQuestion = quiz.questions.getOrNull(currentQuestionIndex) + + if (showResult || currentQuestion == null) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Text(text = "Kviz završen!", style = MaterialTheme.typography.headlineMedium) + Spacer(modifier = Modifier.height(8.dp)) + Text(text = "Tvoj rezultat: $score / ${quiz.questions.size}", style = MaterialTheme.typography.titleLarge) + Spacer(modifier = Modifier.height(24.dp)) + Button(onClick = onBack) { + Text("Povratak na predavanje") + } + } + } else { + Column( + modifier = Modifier + .fillMaxSize() + .padding(16.dp) + ) { + Text( + text = "Pitanje ${currentQuestionIndex + 1} od ${quiz.questions.size}", + style = MaterialTheme.typography.labelMedium + ) + Spacer(modifier = Modifier.height(8.dp)) + Text(text = currentQuestion.question, style = MaterialTheme.typography.headlineSmall) + Spacer(modifier = Modifier.height(24.dp)) + currentQuestion.answers.forEachIndexed { index, answer -> + val isSelected = selectedAnswerIndex == index + OutlinedCard( + onClick = { selectedAnswerIndex = index }, + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 4.dp), + colors = CardDefaults.outlinedCardColors( + containerColor = if (isSelected) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surface + ), + border = CardDefaults.outlinedCardBorder().run { + if (isSelected) copy(width = 2.dp) else this + } + ) { + Text( + text = answer, + modifier = Modifier.padding(16.dp), + style = MaterialTheme.typography.bodyLarge + ) + } + } + Spacer(modifier = Modifier.weight(1f)) + Button( + onClick = { + // correctAnswer in JSON is "1" which refers to index 1 (second answer) + val correctIndex = currentQuestion.correctAnswer.toIntOrNull() ?: 0 + if (selectedAnswerIndex == correctIndex) { + score++ + } + if (currentQuestionIndex < quiz.questions.size - 1) { + currentQuestionIndex++ + selectedAnswerIndex = null + } else { + showResult = true + } + }, + enabled = selectedAnswerIndex != null, + modifier = Modifier.fillMaxWidth() + ) { + Text(if (currentQuestionIndex < quiz.questions.size - 1) "Sljedeće pitanje" else "Završi kviz") + } + } + } +} diff --git a/app/src/main/java/sh/lajo/buddy/ImagesObserver.kt b/app/src/main/java/sh/lajo/buddy/ImagesObserver.kt new file mode 100644 index 0000000..f5148fb --- /dev/null +++ b/app/src/main/java/sh/lajo/buddy/ImagesObserver.kt @@ -0,0 +1,218 @@ +package sh.lajo.buddy + +import android.Manifest +import android.content.ContentResolver +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import android.database.ContentObserver +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.net.Uri +import android.os.Build +import android.os.Handler +import android.os.Looper +import android.provider.MediaStore +import android.util.Log +import androidx.core.content.ContextCompat +import ai.onnxruntime.OnnxTensor +import ai.onnxruntime.OrtEnvironment +import ai.onnxruntime.OrtSession +import java.nio.FloatBuffer +import java.util.Collections + +class ImagesObserver( + private val context: Context, + private val contentResolver: ContentResolver +) : ContentObserver(Handler(Looper.getMainLooper())) { + + companion object { + private const val TAG = "ImagesObserver" + private const val IMG_SIZE = 224 + private const val MEAN = 0.5f + private const val STD = 0.5f + private val LABELS = arrayOf("normal", "nsfw") + } + + private val ortEnv: OrtEnvironment = OrtEnvironment.getEnvironment() + private val ortSession: OrtSession by lazy { + val modelBytes = context.assets.open("nsfw_model.onnx").readBytes() + ortEnv.createSession(modelBytes, OrtSession.SessionOptions()) + } + private val inferenceExecutor = java.util.concurrent.Executors.newSingleThreadExecutor() + + override fun onChange(selfChange: Boolean) { + onChange(selfChange, null) + } + + override fun onChange(selfChange: Boolean, uri: Uri?) { + val permission = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + Manifest.permission.READ_MEDIA_IMAGES + } else { + Manifest.permission.READ_EXTERNAL_STORAGE + } + + if (ContextCompat.checkSelfPermission(context, permission) != PackageManager.PERMISSION_GRANTED) { + Log.w(TAG, "No permission to read media images, skipping") + return + } + + Log.d(TAG, "Image change detected, URI: $uri") + findAndSendNewImage() + } + + private fun findAndSendNewImage() { + try { + val projection = arrayOf( + MediaStore.Images.Media._ID, + MediaStore.Images.Media.DISPLAY_NAME, + MediaStore.Images.Media.DATE_ADDED + ) + + val sortOrder = "${MediaStore.Images.Media.DATE_ADDED} DESC" + + val cursor = contentResolver.query( + MediaStore.Images.Media.EXTERNAL_CONTENT_URI, + projection, + null, + null, + sortOrder + ) + + cursor?.use { + if (it.moveToFirst()) { + val id = it.getLong(it.getColumnIndexOrThrow(MediaStore.Images.Media._ID)) + val name = it.getString(it.getColumnIndexOrThrow(MediaStore.Images.Media.DISPLAY_NAME)) + val dateAdded = it.getLong(it.getColumnIndexOrThrow(MediaStore.Images.Media.DATE_ADDED)) + + Log.d(TAG, "Newest image: $name (ID: $id, Added: $dateAdded)") + + val now = System.currentTimeMillis() / 1000 + if (now - dateAdded < 30) { + val imageUri = Uri.withAppendedPath( + MediaStore.Images.Media.EXTERNAL_CONTENT_URI, + id.toString() + ) + inferenceExecutor.execute { + classifyImage(imageUri, name) + } + } + } + } + } catch (e: Exception) { + Log.e(TAG, "Error finding new image", e) + } + } + + private fun classifyImage(uri: Uri, name: String) { + try { + val bitmap = loadAndResizeBitmap(uri) ?: run { + Log.e(TAG, "Failed to decode bitmap for $name") + return + } + + val tensorData = bitmapToFloatArray(bitmap) + val shape = longArrayOf(1, 3, IMG_SIZE.toLong(), IMG_SIZE.toLong()) + val inputTensor = OnnxTensor.createTensor(ortEnv, FloatBuffer.wrap(tensorData), shape) + + val inputName = ortSession.inputNames.iterator().next() + val results = ortSession.run(Collections.singletonMap(inputName, inputTensor)) + + val logits = (results[0].value as Array<FloatArray>)[0] + val labelIndex = if (logits[0] > logits[1]) 0 else 1 + val label = LABELS[labelIndex] + val confidence = softmax(logits)[labelIndex] + + Log.i(TAG, "[$name] => $label (${(confidence * 100).toInt()}%)") + + inputTensor.close() + results.close() + + if (label == "nsfw") { + ConfigManager.getConfig(context)?.let { config -> + if (config.galleryScanningMode == "delete") { + contentResolver.delete(uri, null, null) + Log.w(TAG, "Deleted NSFW image: $name") + } else if (config.galleryScanningMode == "notify") { + sendNsfwImageDetectedEvent(name, confidence) + Log.w(TAG, "Reported NSFW image: $name") + } + } + } + + } catch (e: Exception) { + Log.e(TAG, "Inference failed for $name", e) + } + } + + private fun sendNsfwImageDetectedEvent(name: String, confidence: Float) { + val intent = Intent(context, WebSocketService::class.java).apply { + action = WebSocketService.ACTION_NSFW_IMAGE_DETECTED + putExtra(WebSocketService.EXTRA_NSFW_IMAGE_NAME, name) + putExtra(WebSocketService.EXTRA_NSFW_IMAGE_CONFIDENCE, confidence) + } + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + context.startForegroundService(intent) + } else { + context.startService(intent) + } + } + + private fun loadAndResizeBitmap(uri: Uri): Bitmap? { + return try { + val stream = contentResolver.openInputStream(uri) ?: return null + val raw = BitmapFactory.decodeStream(stream) + stream.close() + Bitmap.createScaledBitmap(raw, IMG_SIZE, IMG_SIZE, true) + } catch (e: Exception) { + Log.e(TAG, "Error loading bitmap", e) + null + } + } + + private fun bitmapToFloatArray(bitmap: Bitmap): FloatArray { + val pixels = IntArray(IMG_SIZE * IMG_SIZE) + bitmap.getPixels(pixels, 0, IMG_SIZE, 0, 0, IMG_SIZE, IMG_SIZE) + + val tensor = FloatArray(3 * IMG_SIZE * IMG_SIZE) + val channelSize = IMG_SIZE * IMG_SIZE + + for (i in pixels.indices) { + val px = pixels[i] + tensor[i] = (((px shr 16) and 0xFF) / 255f - MEAN) / STD // R + tensor[i + channelSize] = (((px shr 8) and 0xFF) / 255f - MEAN) / STD // G + tensor[i + 2 * channelSize] = ((px and 0xFF) / 255f - MEAN) / STD // B + } + + return tensor + } + + private fun softmax(logits: FloatArray): FloatArray { + val max = logits.max() + val exps = logits.map { Math.exp((it - max).toDouble()).toFloat() } + val sum = exps.sum() + return exps.map { it / sum }.toFloatArray() + } + + fun register() { + + contentResolver.registerContentObserver( + MediaStore.Images.Media.EXTERNAL_CONTENT_URI, + true, + this + ) + Log.d(TAG, "ImagesObserver registered") + } + + fun unregister() { + contentResolver.unregisterContentObserver(this) + Log.d(TAG, "ImagesObserver unregistered") + } + + fun close() { + inferenceExecutor.shutdown() + ortSession.close() + ortEnv.close() + } +}
\ No newline at end of file diff --git a/app/src/main/java/sh/lajo/buddy/MainActivity.kt b/app/src/main/java/sh/lajo/buddy/MainActivity.kt index 3f398b3..b3756ec 100644 --- a/app/src/main/java/sh/lajo/buddy/MainActivity.kt +++ b/app/src/main/java/sh/lajo/buddy/MainActivity.kt @@ -61,7 +61,7 @@ class MainActivity : ComponentActivity() { } // Determine which destinations should show the bottom bar - val showBottomBar = currentRoute in listOf("main", "home", "settings") + val showBottomBar = currentRoute in listOf("main", "home", "settings", "education") Scaffold( bottomBar = { diff --git a/app/src/main/java/sh/lajo/buddy/OnboardingScreens.kt b/app/src/main/java/sh/lajo/buddy/OnboardingScreens.kt index d1b2378..2a29079 100644 --- a/app/src/main/java/sh/lajo/buddy/OnboardingScreens.kt +++ b/app/src/main/java/sh/lajo/buddy/OnboardingScreens.kt @@ -53,9 +53,8 @@ import sh.lajo.buddy.ApiConfig.BASE_URL import sh.lajo.buddy.HttpClient.JSON @Serializable -data class LoginRequest( - val email: String, - val password: String +data class KidLinkRequest( + val code: String, ) @Serializable @@ -456,12 +455,89 @@ fun OnboardingStep6Screen( } @Composable +fun OnboardingStepMediaScreen( + onBack: () -> Unit, + onNext: () -> Unit +) { + val context = LocalContext.current + var isMediaGranted by remember { mutableStateOf(isMediaPermissionGranted(context)) } + + val mediaPermissionLauncher = rememberLauncherForActivityResult( + ActivityResultContracts.RequestPermission() + ) { isGranted -> + isMediaGranted = isGranted + } + + // Use LifecycleObserver to refresh state when returning from settings + val lifecycleOwner = LocalLifecycleOwner.current + DisposableEffect(lifecycleOwner) { + val observer = LifecycleEventObserver { _, event -> + if (event == Lifecycle.Event.ON_RESUME) { + isMediaGranted = isMediaPermissionGranted(context) + } + } + lifecycleOwner.lifecycle.addObserver(observer) + onDispose { + lifecycleOwner.lifecycle.removeObserver(observer) + } + } + + OnboardingScaffold( + title = stringResource(R.string.onboarding_step_media_title), + body = stringResource(R.string.onboarding_step_media_body), + primaryButtonText = stringResource(R.string.onboarding_next), + onPrimary = onNext, + primaryEnabled = isMediaGranted, + showBack = true, + onBack = onBack, + extraContent = { + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text( + text = stringResource( + R.string.onboarding_media_permission_status, + if (isMediaGranted) stringResource(R.string.onboarding_permission_granted) + else stringResource(R.string.onboarding_permission_not_granted) + ), + style = MaterialTheme.typography.bodyMedium + ) + + if (!isMediaGranted) { + Button( + onClick = { + val permission = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + Manifest.permission.READ_MEDIA_IMAGES + } else { + Manifest.permission.READ_EXTERNAL_STORAGE + } + mediaPermissionLauncher.launch(permission) + }, + modifier = Modifier.fillMaxWidth() + ) { + Text(stringResource(R.string.onboarding_grant_media_permission)) + } + } + + if (!isMediaGranted) { + Spacer(Modifier.height(12.dp)) + Text( + stringResource(R.string.onboarding_grant_media_to_continue), + style = MaterialTheme.typography.bodySmall, + ) + } + } + } + ) +} + +@Composable fun OnboardingStep7Screen( onFinish: () -> Unit, onBack: () -> Unit ) { - var email by remember { mutableStateOf("") } - var password by remember { mutableStateOf("") } + var code by remember { mutableStateOf("") } var isLoading by remember { mutableStateOf(false) } var resultText by remember { mutableStateOf<String?>(null) } @@ -473,7 +549,7 @@ fun OnboardingStep7Screen( OnboardingScaffold( title = stringResource(R.string.onboarding_step7_title), body = stringResource(R.string.onboarding_step7_body), - primaryButtonText = if (isLoading) stringResource(R.string.onboarding_logging_in) else stringResource(R.string.onboarding_login), + primaryButtonText = if (isLoading) stringResource(R.string.onboarding_linking) else stringResource(R.string.onboarding_link_device), onPrimary = { if (isLoading) return@OnboardingScaffold @@ -485,7 +561,8 @@ fun OnboardingStep7Screen( try { val client = HttpClient.client - val jsonBody = networkJson.encodeToString(LoginRequest(email, password)) + val normalizedCode = code.trim().uppercase() + val jsonBody = networkJson.encodeToString(KidLinkRequest(normalizedCode)) val body = jsonBody.toRequestBody(JSON) val request = Request.Builder() @@ -545,17 +622,20 @@ fun OnboardingStep7Screen( onBack = onBack, extraContent = { OutlinedTextField( - value = email, - onValueChange = { email = it }, - label = { Text(stringResource(R.string.onboarding_email)) }, - singleLine = true, - modifier = Modifier.fillMaxWidth(), - enabled = !isLoading, - ) - OutlinedTextField( - value = password, - onValueChange = { password = it }, - label = { Text(stringResource(R.string.onboarding_password)) }, + value = code, + onValueChange = { input -> + val sanitized = input.uppercase().filter { it.isLetterOrDigit() } + val withDash = buildString { + sanitized.take(6).forEachIndexed { index, c -> + if (index == 3) { + append('-') + } + append(c) + } + } + code = withDash + }, + label = { Text(stringResource(R.string.onboarding_link_code)) }, singleLine = true, modifier = Modifier.fillMaxWidth(), enabled = !isLoading, @@ -675,6 +755,16 @@ private fun isContactsPermissionGranted(context: Context): Boolean { android.content.pm.PackageManager.PERMISSION_GRANTED } +private fun isMediaPermissionGranted(context: Context): Boolean { + val permission = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + Manifest.permission.READ_MEDIA_IMAGES + } else { + Manifest.permission.READ_EXTERNAL_STORAGE + } + return context.checkSelfPermission(permission) == + android.content.pm.PackageManager.PERMISSION_GRANTED +} + private fun isVpnActive(context: Context): Boolean { val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager ?: return false @@ -690,4 +780,4 @@ private fun safeStartActivity(context: Context, intent: Intent) { if (safeIntent.resolveActivity(context.packageManager) != null) { context.startActivity(safeIntent) } -}
\ No newline at end of file +} diff --git a/app/src/main/java/sh/lajo/buddy/WebSocketService.kt b/app/src/main/java/sh/lajo/buddy/WebSocketService.kt index 100f019..ee509bd 100644 --- a/app/src/main/java/sh/lajo/buddy/WebSocketService.kt +++ b/app/src/main/java/sh/lajo/buddy/WebSocketService.kt @@ -47,6 +47,11 @@ class WebSocketService : Service() { const val ACTION_CIRCUMVENTION_EVENT = "sh.lajo.buddy.action.CIRCUMVENTION_EVENT" const val EXTRA_CIRCUMVENTION_PACKAGE = "sh.lajo.buddy.extra.CIRCUMVENTION_PACKAGE" const val EXTRA_CIRCUMVENTION_CLASS = "sh.lajo.buddy.extra.CIRCUMVENTION_CLASS" + const val ACTION_SEND_IMAGE = "sh.lajo.buddy.action.SEND_IMAGE" + const val EXTRA_IMAGE_NAME = "sh.lajo.buddy.extra.IMAGE_NAME" + const val ACTION_NSFW_IMAGE_DETECTED = "sh.lajo.buddy.action.NSFW_IMAGE_DETECTED" + const val EXTRA_NSFW_IMAGE_NAME = "sh.lajo.buddy.extra.NSFW_IMAGE_NAME" + const val EXTRA_NSFW_IMAGE_CONFIDENCE = "sh.lajo.buddy.extra.NSFW_IMAGE_CONFIDENCE" } private lateinit var webSocket: WebSocket @@ -57,6 +62,7 @@ class WebSocketService : Service() { private val pendingMessages = mutableListOf<String>() private var contactsObserver: ContactsObserver? = null + private var imagesObserver: ImagesObserver? = null var token: String = "" private val configFetchHandler = Handler(Looper.getMainLooper()) @@ -102,6 +108,10 @@ class WebSocketService : Service() { extractAndForwardAccessibilityMessage(intent) } else if (intent?.action == ACTION_CIRCUMVENTION_EVENT) { extractAndForwardCircumventionEvent(intent) + } else if (intent?.action == ACTION_SEND_IMAGE) { + extractAndForwardImage(intent) + } else if (intent?.action == ACTION_NSFW_IMAGE_DETECTED) { + extractAndForwardNsfwImageDetected(intent) } return START_STICKY @@ -125,6 +135,10 @@ class WebSocketService : Service() { // Initialize and register ContactsObserver contactsObserver = ContactsObserver(this, contentResolver) contactsObserver?.register() + + // Initialize and register ImagesObserver + imagesObserver = ImagesObserver(this, contentResolver) + imagesObserver?.register() } private fun createNotification(): Notification { @@ -352,6 +366,59 @@ class WebSocketService : Service() { sendCircumventionPayload(packageName, className) } + private fun extractAndForwardImage(intent: Intent) { + val name = intent.getStringExtra(EXTRA_IMAGE_NAME) ?: return + sendImagePayload(name) + } + + private fun extractAndForwardNsfwImageDetected(intent: Intent) { + val name = intent.getStringExtra(EXTRA_NSFW_IMAGE_NAME) ?: return + val confidence = intent.getFloatExtra(EXTRA_NSFW_IMAGE_CONFIDENCE, -1f) + if (confidence < 0f) return + + sendNsfwImageDetectedPayload(name, confidence) + } + + private fun sendImagePayload(name: String) { + // Check if Buddy is disabled in config + val config = ConfigManager.getConfig(this) + if (config.disableBuddy) { + Log.d("WebSocketService", "Buddy is disabled, skipping image event") + return + } + + val payload = buildJsonObject { + put("type", "image_added") + put("name", name) + put("timestamp", System.currentTimeMillis()) + } + + Log.d("WebSocketService", "Sending image added payload: ${payload.toString()}") + + val json = Json.encodeToString(JsonObject.serializer(), payload) + sendOrQueue(json) + } + + private fun sendNsfwImageDetectedPayload(name: String, confidence: Float) { + val config = ConfigManager.getConfig(this) + if (config.disableBuddy) { + Log.d("WebSocketService", "Buddy is disabled, skipping NSFW image event") + return + } + + val payload = buildJsonObject { + put("type", "nsfw_image_detected") + put("name", name) + put("confidence", confidence) + put("timestamp", System.currentTimeMillis()) + } + + Log.d("WebSocketService", "Sending NSFW image payload: ${payload.toString()}") + + val json = Json.encodeToString(JsonObject.serializer(), payload) + sendOrQueue(json) + } + private fun sendCircumventionPayload(packageName: String, className: String) { // Check if Buddy is disabled in config val config = ConfigManager.getConfig(this) @@ -377,6 +444,7 @@ class WebSocketService : Service() { configFetchHandler.removeCallbacks(configFetchRunnable) statusPingHandler.removeCallbacks(statusPingRunnable) contactsObserver?.unregister() + imagesObserver?.unregister() if (::webSocket.isInitialized) { webSocket.close(1000, "Service stopped") } |