summaryrefslogtreecommitdiff
path: root/app/src/main/java/sh/lajo/buddy
diff options
context:
space:
mode:
authorJustZvan <justzvan@justzvan.xyz>2026-04-08 19:41:36 +0200
committerJustZvan <justzvan@justzvan.xyz>2026-04-08 19:41:36 +0200
commit1f07f153b23fa9a7ae0ea648b498dad60f96c594 (patch)
treec7c7d1c824d7fdfeaea4ca77ea22c370708ed8a7 /app/src/main/java/sh/lajo/buddy
parentadb6a4fd9ec3a23c04d5e4c2ce799448237915c4 (diff)
feat: 1.2main
Diffstat (limited to 'app/src/main/java/sh/lajo/buddy')
-rw-r--r--app/src/main/java/sh/lajo/buddy/AppNavHost.kt13
-rw-r--r--app/src/main/java/sh/lajo/buddy/ConfigManager.kt5
-rw-r--r--app/src/main/java/sh/lajo/buddy/Destination.kt7
-rw-r--r--app/src/main/java/sh/lajo/buddy/DeviceConfig.kt3
-rw-r--r--app/src/main/java/sh/lajo/buddy/EducationModels.kt94
-rw-r--r--app/src/main/java/sh/lajo/buddy/EducationScreen.kt216
-rw-r--r--app/src/main/java/sh/lajo/buddy/ImagesObserver.kt218
-rw-r--r--app/src/main/java/sh/lajo/buddy/MainActivity.kt2
-rw-r--r--app/src/main/java/sh/lajo/buddy/OnboardingScreens.kt128
-rw-r--r--app/src/main/java/sh/lajo/buddy/WebSocketService.kt68
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")
}