diff --git a/ReconnectED-Android/.gitignore b/ReconnectED-Android/.gitignore index 347e252..5c84e51 100644 --- a/ReconnectED-Android/.gitignore +++ b/ReconnectED-Android/.gitignore @@ -1,6 +1,7 @@ # Gradle files .gradle/ build/ +.kotlin/ # Local configuration file (sdk path, etc) local.properties diff --git a/ReconnectED-Android/app/build.gradle.kts b/ReconnectED-Android/app/build.gradle.kts index 9b05fff..91d3e40 100644 --- a/ReconnectED-Android/app/build.gradle.kts +++ b/ReconnectED-Android/app/build.gradle.kts @@ -39,10 +39,22 @@ android { viewBinding = true buildConfig = true } + splits { + abi { + // https://developer.android.com/build/configure-apk-splits#kts + isEnable = true // enable multiple APKs per ABI + reset() // reset included ABIs + include("armeabi-v7a", "arm64-v8a", "x86_64") + isUniversalApk = true + } + } } dependencies { implementation(libs.firebase.ai) + implementation(libs.firebase.firestore) + implementation(libs.androidx.datastore) + implementation(libs.gson) implementation(libs.androidx.compose.foundation) androidTestImplementation(libs.androidx.espresso.core) androidTestImplementation(libs.androidx.junit) diff --git a/ReconnectED-Android/app/src/main/AndroidManifest.xml b/ReconnectED-Android/app/src/main/AndroidManifest.xml index 55e31ed..fbeb58c 100644 --- a/ReconnectED-Android/app/src/main/AndroidManifest.xml +++ b/ReconnectED-Android/app/src/main/AndroidManifest.xml @@ -2,9 +2,15 @@ + + + + + + @@ -21,7 +27,6 @@ android:fullBackupContent="@xml/backup_rules" android:icon="@mipmap/ic_launcher" android:label="@string/app_name" - android:roundIcon="@mipmap/ic_launcher_round" android:supportsRtl="true" android:theme="@style/Theme.App.Starting"> + + + + + + + + + + + diff --git a/ReconnectED-Android/app/src/main/assets/quotes.json b/ReconnectED-Android/app/src/main/assets/quotes.json new file mode 100644 index 0000000..845536f --- /dev/null +++ b/ReconnectED-Android/app/src/main/assets/quotes.json @@ -0,0 +1,286 @@ +[ + { + "quote": "Digital detox is not about disconnecting, but reconnecting.", + "author": "Anonymous" + }, + { + "quote": "Almost everything will work again if you unplug it for a few minutes, including you.", + "author": "Anne Lamott" + }, + { + "quote": "You don’t have to disconnect from the world, just from the noise.", + "author": "Anonymous" + }, + { + "quote": "Clarity comes when the screens go dark.", + "author": "Anonymous" + }, + { + "quote": "Technology is a useful servant but a dangerous master.", + "author": "Christian Lous Lange" + }, + { + "quote": "The best productivity hack is still a good night’s sleep.", + "author": "Anonymous" + }, + { + "quote": "Focus is the new IQ.", + "author": "Cal Newport" + }, + { + "quote": "Your attention is your most valuable currency.", + "author": "Anonymous" + }, + { + "quote": "Detox your mind by decluttering your digital life.", + "author": "Anonymous" + }, + { + "quote": "Productivity is never an accident. It is always the result of a commitment to excellence, intelligent planning, and focused effort.", + "author": "Paul J. Meyer" + }, + { + "quote": "Almost everything distracting you is less important than you think.", + "author": "Anonymous" + }, + { + "quote": "The price of productivity is the courage to say no.", + "author": "Anonymous" + }, + { + "quote": "Do not disturb is a productivity superpower.", + "author": "Anonymous" + }, + { + "quote": "The more you check your phone, the less you check in with yourself.", + "author": "Anonymous" + }, + { + "quote": "Simplicity is the ultimate sophistication.", + "author": "Leonardo da Vinci" + }, + { + "quote": "Time is what we want most, but what we use worst.", + "author": "William Penn" + }, + { + "quote": "Your phone should serve you, not enslave you.", + "author": "Anonymous" + }, + { + "quote": "The ability to focus is a competitive advantage.", + "author": "Anonymous" + }, + { + "quote": "Don’t confuse being busy with being productive.", + "author": "Anonymous" + }, + { + "quote": "Sometimes the most productive thing you can do is rest.", + "author": "Mark Black" + }, + { + "quote": "Disconnect to reconnect with what matters.", + "author": "Anonymous" + }, + { + "quote": "Beware the barrenness of a busy life.", + "author": "Socrates" + }, + { + "quote": "Productivity is being able to do things that you were never able to do before.", + "author": "Franz Kafka" + }, + { + "quote": "Your attention is being mined. Guard it fiercely.", + "author": "Anonymous" + }, + { + "quote": "The key is not to prioritize what’s on your schedule, but to schedule your priorities.", + "author": "Stephen Covey" + }, + { + "quote": "Digital detox is a gift of presence to yourself and others.", + "author": "Anonymous" + }, + { + "quote": "Focus on being productive instead of busy.", + "author": "Tim Ferriss" + }, + { + "quote": "Almost all creativity requires purposeful un-plugging.", + "author": "Anonymous" + }, + { + "quote": "The shorter way to do many things is to only do one thing at a time.", + "author": "Mozart" + }, + { + "quote": "Your phone is a tool, not a tether.", + "author": "Anonymous" + }, + { + "quote": "The future depends on what you do today.", + "author": "Mahatma Gandhi" + }, + { + "quote": "Silence is sometimes the best productivity hack.", + "author": "Anonymous" + }, + { + "quote": "Don’t get so busy making a living that you forget to make a life.", + "author": "Dolly Parton" + }, + { + "quote": "The best way to get something done is to begin.", + "author": "Anonymous" + }, + { + "quote": "Digital detox is not deprivation, it’s liberation.", + "author": "Anonymous" + }, + { + "quote": "Lost time is never found again.", + "author": "Benjamin Franklin" + }, + { + "quote": "The art of being wise is knowing what to overlook.", + "author": "William James" + }, + { + "quote": "Your brain is not designed for constant notifications.", + "author": "Anonymous" + }, + { + "quote": "Concentration is the secret of strength.", + "author": "Ralph Waldo Emerson" + }, + { + "quote": "Technology should enhance life, not consume it.", + "author": "Anonymous" + }, + { + "quote": "The way to get started is to quit talking and begin doing.", + "author": "Walt Disney" + }, + { + "quote": "Digital detox is a reset button for your mind.", + "author": "Anonymous" + }, + { + "quote": "Productivity is less about time management and more about mind management.", + "author": "Anonymous" + }, + { + "quote": "The ability to simplify means to eliminate the unnecessary so that the necessary may speak.", + "author": "Hans Hofmann" + }, + { + "quote": "Your best ideas rarely come when you’re scrolling.", + "author": "Anonymous" + }, + { + "quote": "Don’t watch the clock; do what it does. Keep going.", + "author": "Sam Levenson" + }, + { + "quote": "The more you subtract, the more you add to your life.", + "author": "Anonymous" + }, + { + "quote": "Focus is the art of knowing what to ignore.", + "author": "Anonymous" + }, + { + "quote": "Digital detox is self-care in the modern age.", + "author": "Anonymous" + }, + { + "quote": "The successful warrior is the average man, with laser-like focus.", + "author": "Bruce Lee" + }, + { + "quote": "Your phone steals your mornings if you let it.", + "author": "Anonymous" + }, + { + "quote": "Don’t confuse motion with progress.", + "author": "Anonymous" + }, + { + "quote": "The greatest wealth is to live content with little.", + "author": "Plato" + }, + { + "quote": "Digital detox is not about less life, but more real life.", + "author": "Anonymous" + }, + { + "quote": "Productivity is never about doing more things, but doing the right things.", + "author": "Anonymous" + }, + { + "quote": "The best way to focus is to remove distractions, not add willpower.", + "author": "Anonymous" + }, + { + "quote": "Happiness is not having what you want. It is wanting what you have.", + "author": "Anonymous" + }, + { + "quote": "Your attention is the battlefield of the 21st century.", + "author": "Anonymous" + }, + { + "quote": "Don’t let your phone be the first thing you touch in the morning.", + "author": "Anonymous" + }, + { + "quote": "The key to productivity is to do less, better.", + "author": "Anonymous" + }, + { + "quote": "Digital detox is a vacation for your brain.", + "author": "Anonymous" + }, + { + "quote": "The secret of getting ahead is getting started.", + "author": "Mark Twain" + }, + { + "quote": "Your focus determines your reality.", + "author": "George Lucas" + }, + { + "quote": "Don’t drown in information, starve for wisdom.", + "author": "Anonymous" + }, + { + "quote": "The best productivity system is the one you actually use.", + "author": "Anonymous" + }, + { + "quote": "Digital detox is a way of reclaiming your time.", + "author": "Anonymous" + }, + { + "quote": "The man who moves a mountain begins by carrying away small stones.", + "author": "Confucius" + }, + { + "quote": "Your phone is not your life; it’s just a device.", + "author": "Anonymous" + }, + { + "quote": "Don’t confuse activity with achievement.", + "author": "John Wooden" + }, + { + "quote": "Digital detox is not about losing connection, but gaining perspective.", + "author": "Anonymous" + }, + { + "quote": "The key to productivity is focus, not frenzy.", + "author": "Anonymous" + } +] diff --git a/ReconnectED-Android/app/src/main/ic_launcher-playstore.png b/ReconnectED-Android/app/src/main/ic_launcher-playstore.png new file mode 100644 index 0000000..8e92fc6 --- /dev/null +++ b/ReconnectED-Android/app/src/main/ic_launcher-playstore.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:dde531b768884e8c6b2ae670c39a74084ac1d04b26fd965b0e26c4fc27f2e0a3 +size 199918 diff --git a/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/activities/AppBlockedActivity.kt b/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/activities/AppBlockedActivity.kt new file mode 100644 index 0000000..66a52b9 --- /dev/null +++ b/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/activities/AppBlockedActivity.kt @@ -0,0 +1,196 @@ +package com.getreconnected.reconnected.activities + +import android.content.Intent +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Lock +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.getreconnected.reconnected.core.util.formatTime +import com.getreconnected.reconnected.ui.theme.ReconnectEDTheme +import com.getreconnected.reconnected.ui.theme.interDisplayFamily + +/** + * Activity that is displayed when an app's usage limit is reached. + * Shows a blocking screen and sends the user back to the home screen. + */ +class AppBlockedActivity : ComponentActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + val packageName = intent.getStringExtra("package_name") ?: "" + val appName = intent.getStringExtra("app_name") ?: "This app" + val limitMillis = intent.getLongExtra("limit_millis", 0L) + + setContent { + ReconnectEDTheme { + AppBlockedScreen( + appName = appName, + limitMillis = limitMillis, + onGoHome = { + goToHomeScreen() + finish() + }, + ) + } + } + } + + private fun goToHomeScreen() { + val homeIntent = + Intent(Intent.ACTION_MAIN).apply { + addCategory(Intent.CATEGORY_HOME) + flags = Intent.FLAG_ACTIVITY_NEW_TASK + } + startActivity(homeIntent) + } +} + +/** + * Composable screen that displays the app blocked message. + */ +@Composable +@Suppress("ktlint:standard:function-naming") +fun AppBlockedScreen( + appName: String, + limitMillis: Long, + onGoHome: () -> Unit, +) { + Column( + modifier = + Modifier + .fillMaxSize() + .background( + brush = + Brush.verticalGradient( + colors = + listOf( + Color(0xFFEF4444), // Red + Color(0xFFF97316), // Orange + ), + ), + ).padding(32.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Icon( + imageVector = Icons.Default.Lock, + contentDescription = "Locked", + modifier = Modifier.size(120.dp), + tint = Color.White, + ) + + Spacer(modifier = Modifier.height(32.dp)) + + Text( + text = "Time's Up!", + style = + TextStyle( + fontFamily = interDisplayFamily, + fontSize = 48.sp, + fontWeight = FontWeight.Bold, + color = Color.White, + ), + ) + + Spacer(modifier = Modifier.height(16.dp)) + + Text( + text = "You've reached your limit for", + style = + TextStyle( + fontFamily = interDisplayFamily, + fontSize = 20.sp, + fontWeight = FontWeight.Normal, + color = Color.White.copy(alpha = 0.9f), + ), + textAlign = TextAlign.Center, + ) + + Spacer(modifier = Modifier.height(8.dp)) + + Text( + text = appName, + style = + TextStyle( + fontFamily = interDisplayFamily, + fontSize = 28.sp, + fontWeight = FontWeight.Bold, + color = Color.White, + ), + textAlign = TextAlign.Center, + ) + + Spacer(modifier = Modifier.height(24.dp)) + + Text( + text = "Daily limit: ${formatTime(limitMillis)}", + style = + TextStyle( + fontFamily = interDisplayFamily, + fontSize = 18.sp, + fontWeight = FontWeight.Medium, + color = Color.White.copy(alpha = 0.8f), + ), + ) + + Spacer(modifier = Modifier.height(48.dp)) + + Text( + text = "Take a break and reconnect with what matters!", + style = + TextStyle( + fontFamily = interDisplayFamily, + fontSize = 16.sp, + fontWeight = FontWeight.Normal, + color = Color.White.copy(alpha = 0.9f), + ), + textAlign = TextAlign.Center, + ) + + Spacer(modifier = Modifier.height(32.dp)) + + Button( + onClick = onGoHome, + colors = + ButtonDefaults.buttonColors( + containerColor = Color.White, + contentColor = Color(0xFFEF4444), + ), + modifier = Modifier.height(56.dp), + ) { + Text( + text = "Go to Home Screen", + style = + TextStyle( + fontFamily = interDisplayFamily, + fontSize = 18.sp, + fontWeight = FontWeight.Bold, + ), + ) + } + } +} diff --git a/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/activities/MainActivity.kt b/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/activities/MainActivity.kt index e4dfe50..ea22c9b 100644 --- a/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/activities/MainActivity.kt +++ b/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/activities/MainActivity.kt @@ -1,11 +1,16 @@ package com.getreconnected.reconnected.activities +import android.Manifest +import android.content.pm.PackageManager +import android.os.Build import android.os.Bundle import android.util.Log import androidx.activity.ComponentActivity import androidx.activity.compose.setContent +import androidx.activity.result.contract.ActivityResultContracts +import androidx.core.content.ContextCompat import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen -import com.getreconnected.reconnected.core.DatabaseManager +import com.getreconnected.reconnected.services.AppLimitMonitorService import com.getreconnected.reconnected.ui.AppNavigation import com.getreconnected.reconnected.ui.theme.ReconnectEDTheme @@ -13,17 +18,62 @@ import com.getreconnected.reconnected.ui.theme.ReconnectEDTheme * The main activity for the app. */ class MainActivity : ComponentActivity() { + // Notification permission launcher for Android 13+ + private val notificationPermissionLauncher = + registerForActivityResult(ActivityResultContracts.RequestPermission()) { isGranted -> + if (isGranted) { + Log.d("MainActivity", "Notification permission granted") + startMonitoringService() + } else { + Log.w("MainActivity", "Notification permission denied") + // Service will still start, but notifications won't show + startMonitoringService() + } + } + override fun onCreate(savedInstanceState: Bundle?) { Log.d("MainActivity", "Starting main activity") installSplashScreen() // This must be called BEFORE super.onCreate() super.onCreate(savedInstanceState) - // Get database instance - Log.d("MainActivity", "Getting database instance") - val database = DatabaseManager.getDatabase(this) - Log.d("MainActivity", "Setting content") - setContent { ReconnectEDTheme { AppNavigation(database) } } + setContent { ReconnectEDTheme { AppNavigation() } } + + // Request notification permission on Android 13+ and start monitoring service + requestNotificationPermissionAndStartService() + } + + override fun onDestroy() { + super.onDestroy() + // Note: We don't stop the service here as it should run in the background + // The service will be stopped when the user disables monitoring or logs out + } + + private fun requestNotificationPermissionAndStartService() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + when { + ContextCompat.checkSelfPermission( + this, + Manifest.permission.POST_NOTIFICATIONS, + ) == PackageManager.PERMISSION_GRANTED -> { + startMonitoringService() + } + else -> { + notificationPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS) + } + } + } else { + startMonitoringService() + } + } + + private fun startMonitoringService() { + try { + AppLimitMonitorService.start(this) + Log.d("MainActivity", "App limit monitoring service started") + } catch (e: Exception) { + Log.e("MainActivity", "Failed to start monitoring service", e) + } } } diff --git a/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/core/AppUsageRepository.kt b/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/core/AppUsageRepository.kt index 878f8c1..80e3a52 100644 --- a/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/core/AppUsageRepository.kt +++ b/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/core/AppUsageRepository.kt @@ -3,18 +3,20 @@ package com.getreconnected.reconnected.core import android.app.usage.UsageStatsManager import android.content.Context import android.content.pm.PackageManager -import com.getreconnected.reconnected.core.models.AppUsageInfo +import android.util.Log +import com.getreconnected.reconnected.core.models.entities.AppUsageInfo import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext +import java.text.SimpleDateFormat import java.util.Calendar +import java.util.Locale /** * Repository for fetching application usage statistics from the device. * - * This class provides methods to retrieve usage data about applications - * over a specific period of time. It interacts with the system's UsageStatsManager - * to gather detailed information about app usage such as app name, package name, - * usage duration, and app icon. + * This class provides methods to retrieve usage data about applications over a specific period of + * time. It interacts with the system's UsageStatsManager to gather detailed information about app + * usage such as app name, package name, usage duration, and app icon. * * @constructor Initializes the repository with the given application context. */ @@ -22,9 +24,13 @@ class AppUsageRepository( private val context: Context, ) { /** - * Retrieves usage statistics for the last 24 hours. + * Retrieves daily application usage statistics from midnight (00:00) to now. * - * @return A list of [AppUsageInfo] objects representing the usage statistics. + * This uses the same time range as `AppLimitMonitorService.getAppUsageToday()` to ensure + * consistent usage tracking between UI display and limit enforcement. Usage resets + * daily at midnight (00:00). + * + * @return A list of [AppUsageInfo] objects representing today's usage statistics. */ suspend fun getDailyUsageStats(): List = withContext(Dispatchers.IO) { @@ -32,10 +38,115 @@ class AppUsageRepository( val usageStatsManager = context.getSystemService(Context.USAGE_STATS_SERVICE) as UsageStatsManager val packageManager = context.packageManager + // Get usage from TODAY (midnight to now) to match AppLimitMonitorService val calendar = Calendar.getInstance() - val endTime = calendar.timeInMillis - calendar.add(Calendar.DAY_OF_YEAR, -1) + calendar.set(Calendar.HOUR_OF_DAY, 0) + calendar.set(Calendar.MINUTE, 0) + calendar.set(Calendar.SECOND, 0) + calendar.set(Calendar.MILLISECOND, 0) val startTime = calendar.timeInMillis + val endTime = System.currentTimeMillis() + + val usageStatsList = + usageStatsManager.queryUsageStats( + UsageStatsManager.INTERVAL_BEST, + startTime, + endTime, + ) + + usageStatsList + .filter { it.totalTimeInForeground > 0 } + .mapNotNull { usageStats -> + try { + val applicationInfo = packageManager.getApplicationInfo(usageStats.packageName, 0) + val appName = packageManager.getApplicationLabel(applicationInfo).toString() + val appIcon = packageManager.getApplicationIcon(applicationInfo) + AppUsageInfo( + appName = appName, + packageName = usageStats.packageName, + usageTime = usageStats.totalTimeInForeground, + appIcon = appIcon, + ) + } catch (e: PackageManager.NameNotFoundException) { + null + } + }.sortedByDescending { it.usageTime } + } + + /** + * Retrieves the weekly application usage statistics. Uses the `UsageStatsManager` to collect + * usage data over the last 7 days and returns a map of day to usage time in minutes. + * + * @return A map where the key is the day of the week (e.g., "Mon") and the value is the total + * screen time in minutes. + */ + suspend fun getWeeklyUsageStats(): Map = + withContext(Dispatchers.IO) { + val usageStatsManager = context.getSystemService(Context.USAGE_STATS_SERVICE) as UsageStatsManager + val weeklyUsage = linkedMapOf() + val cal = Calendar.getInstance() + val dayFormat = SimpleDateFormat("EEE", Locale.getDefault()) + + for (i in 6 downTo 0) { + cal.time = java.util.Date() + cal.add(Calendar.DAY_OF_YEAR, -i) + val dayKey = dayFormat.format(cal.time) + + cal.set(Calendar.HOUR_OF_DAY, 0) + cal.set(Calendar.MINUTE, 0) + cal.set(Calendar.SECOND, 0) + cal.set(Calendar.MILLISECOND, 0) + val startTime = cal.timeInMillis + + cal.add(Calendar.DAY_OF_YEAR, 1) + cal.add(Calendar.MILLISECOND, -1) + val endTime = cal.timeInMillis + + val usageStats = + usageStatsManager.queryUsageStats( + UsageStatsManager.INTERVAL_DAILY, + startTime, + endTime, + ) + + val totalTime = usageStats.sumOf { it.totalTimeInForeground } + + Log.d("WeeklyUsageStats", "Start Time: ${java.util.Date(startTime)}") + Log.d("WeeklyUsageStats", "End Time: ${java.util.Date(endTime)}") + Log.d("WeeklyUsageStats", "Day: $dayKey") + Log.d("WeeklyUsageStats", "Total Usage: $totalTime") + + weeklyUsage[dayKey] = totalTime / (1000 * 60) // Convert to minutes + } + weeklyUsage + } + + /** + * Retrieves usage statistics for a specific date. + * + * @param date The date to retrieve usage statistics for (as Calendar instance). + * @return A list of [AppUsageInfo] objects representing the usage statistics for that date. + */ + suspend fun getUsageStatsForDate(date: Calendar): List = + withContext(Dispatchers.IO) { + val usageStatsManager = context.getSystemService(Context.USAGE_STATS_SERVICE) as UsageStatsManager + val packageManager = context.packageManager + + // Set to start of day + val startCal = date.clone() as Calendar + startCal.set(Calendar.HOUR_OF_DAY, 0) + startCal.set(Calendar.MINUTE, 0) + startCal.set(Calendar.SECOND, 0) + startCal.set(Calendar.MILLISECOND, 0) + val startTime = startCal.timeInMillis + + // Set to end of day + val endCal = date.clone() as Calendar + endCal.set(Calendar.HOUR_OF_DAY, 23) + endCal.set(Calendar.MINUTE, 59) + endCal.set(Calendar.SECOND, 59) + endCal.set(Calendar.MILLISECOND, 999) + val endTime = endCal.timeInMillis val usageStatsList = usageStatsManager.queryUsageStats( diff --git a/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/core/DatabaseManager.kt b/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/core/DatabaseManager.kt deleted file mode 100644 index e470a99..0000000 --- a/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/core/DatabaseManager.kt +++ /dev/null @@ -1,67 +0,0 @@ -package com.getreconnected.reconnected.core - -import android.content.Context -import androidx.room.Database -import androidx.room.Room -import androidx.room.RoomDatabase -import com.getreconnected.reconnected.core.models.entities.User -import com.getreconnected.reconnected.core.models.entities.UserDao - -/** - * Abstract class for managing the application's database using Room. - * - * This class serves as the main access point to the database. It defines the entities associated - * with the database and the version of the database schema. It also provides an abstract method - * to access the DAO (Data Access Object) for interacting with the `User` entity. - * - * Annotations: - * - `@Database` specifies the list of entities (`User`) related to this database and the schema version. - */ -@Database(entities = [User::class], version = 1) -abstract class AppDatabase : RoomDatabase() { - abstract fun userDao(): UserDao -} - -/** - * Responsible for managing a single instance of the `AppDatabase` using the Singleton pattern. - * - * This class ensures that the database is initialized only once and provides a synchronized method - * to access the instance of the `AppDatabase`. It uses Room to build the database instance and serves - * as the central access point for the application's database. - * - * Thread Safety: - * The database instance is fetched or created in a thread-safe manner by using a synchronized block - * and the `@Volatile` annotation to ensure visibility and consistency across threads. - * - */ -class DatabaseManager private constructor() { - companion object { - @Volatile - @Suppress("ktlint:standard:property-naming") - private var INSTANCE: AppDatabase? = null - - /** - * Retrieves the singleton instance of the `AppDatabase`. - * - * @param context The application context. - * @return The singleton instance of the `AppDatabase`. - */ - fun getDatabase(context: Context): AppDatabase { - val tempInstance = INSTANCE - if (tempInstance != null) { - return tempInstance - } - synchronized(this) { - val instance = - Room - .databaseBuilder( - context.applicationContext, - AppDatabase::class.java, - "reconnected", - ).build() - INSTANCE = instance - return instance - } - } - } -} diff --git a/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/core/Info.kt b/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/core/Info.kt index e12e60c..c388db7 100644 --- a/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/core/Info.kt +++ b/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/core/Info.kt @@ -5,6 +5,12 @@ data object Application { const val DESCRIPTION = "Minimize the overconsumption of the internet." } +data object FilePaths { + const val USER = "user-data.json" + const val AVATAR = "user-avatar.png" + const val QUOTES = "quotes.json" +} + data object Chatbot { const val MODEL = "gemini-2.5-flash" val INITIAL_PROMPT = diff --git a/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/core/Utils.kt b/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/core/Utils.kt deleted file mode 100644 index 9b04eb6..0000000 --- a/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/core/Utils.kt +++ /dev/null @@ -1,18 +0,0 @@ -package com.getreconnected.reconnected.core - -/** - * Formats the given time in milliseconds to a human-readable string. - * - * @param t The time in milliseconds to format. - * @return A string representing the formatted time in the format "h m s". - */ -fun formatTime(t: Long): String { - val seconds = (t / 1000) % 60 - val minutes = (t / (1000 * 60)) % 60 - val hours = (t / (1000 * 60 * 60)) % 24 - return when { - hours > 0 -> "${hours}h ${minutes}m ${seconds}s" - minutes > 0 -> "${minutes}m ${seconds}s" - else -> "${seconds}s" - } -} diff --git a/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/core/chatbot/ChatManager.kt b/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/core/chatbot/ChatManager.kt deleted file mode 100644 index b565468..0000000 --- a/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/core/chatbot/ChatManager.kt +++ /dev/null @@ -1,100 +0,0 @@ -package com.getreconnected.reconnected.core.chatbot - -import com.getreconnected.reconnected.core.Chatbot -import com.getreconnected.reconnected.core.models.Chat -import com.google.firebase.Firebase -import com.google.firebase.ai.GenerativeModel -import com.google.firebase.ai.ai -import com.google.firebase.ai.type.GenerativeBackend -import com.google.firebase.ai.type.HarmBlockThreshold -import com.google.firebase.ai.type.HarmCategory -import com.google.firebase.ai.type.SafetySetting -import com.google.firebase.ai.type.content - -object ChatManager { - var model: GenerativeModel? = null - - /** - * Initiates a chat session with a predefined conversation starter. - * - * @param name The name of the user starting the chat. This will be included in the initial message. - */ - fun startChat(name: String?): com.google.firebase.ai.Chat { - model = - Firebase - .ai(backend = GenerativeBackend.googleAI()) - .generativeModel( - modelName = Chatbot.MODEL, - systemInstruction = content { text(generateInitialPrompt(name)) }, - safetySettings = - listOf( - SafetySetting(HarmCategory.HARASSMENT, HarmBlockThreshold.ONLY_HIGH), - SafetySetting(HarmCategory.DANGEROUS_CONTENT, HarmBlockThreshold.ONLY_HIGH), - SafetySetting(HarmCategory.HATE_SPEECH, HarmBlockThreshold.ONLY_HIGH), - SafetySetting(HarmCategory.SEXUALLY_EXPLICIT, HarmBlockThreshold.ONLY_HIGH), - ), - ) - if (model == null) { - throw Exception("Model is null") - } - - return model!!.startChat( - history = - listOf( - content(role = "model") { text(Chatbot.INITIAL_RESPONSE) }, - ), - ) - } - - /** - * Generates a response by sending the given prompt to a generative model. - * - * @param prompt The input string used to generate a response. - * @return A Chat object containing the generated response text and additional metadata. - */ - suspend fun getResponse(prompt: String): Chat { - try { - if (model == null) { - throw Exception("Model is null") - } - val response = model!!.generateContent(prompt) - return Chat( - prompt = response.text ?: "error", - bitmap = null, - isFromUser = false, - ) - } catch (e: Exception) { - return Chat( - prompt = e.message ?: "error", - bitmap = null, - isFromUser = false, - ) - } - } - - /** - * Generates an initial chatbot prompt customized with the user's information. - * - * @param name The name of the user to be included in the generated prompt. - * @return A string containing the initial chatbot prompt with user-specific details and screen time breakdown. - */ - fun generateInitialPrompt(name: String?): String { - // TODO: Integrate the data aggregation system to get this information - val daysSinceStarted = 8 - val screenTimeTotal = 380.42 - return Chatbot.INITIAL_PROMPT + - """ - | - |The user has this information: - |- Name: ${name ?: "Unable to retrieve name"} - |- Days since started: $daysSinceStarted - | - |Currently, the user's screen time is $screenTimeTotal minutes. This is a breakdown of the total screen time: - |- "Facebook": 137 minutes - |- "Instagram": 121.71 minutes - |- "Call of Duty: Mobile": 61.14 - |- "Chrome": 40.57 minutes - |- "YouTube": 20 minutes - """.trimMargin() - } -} diff --git a/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/core/dataManager/ChatManager.kt b/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/core/dataManager/ChatManager.kt new file mode 100644 index 0000000..d412eb7 --- /dev/null +++ b/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/core/dataManager/ChatManager.kt @@ -0,0 +1,136 @@ +package com.getreconnected.reconnected.core.dataManager + +import android.content.Context +import android.util.Log +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import com.getreconnected.reconnected.core.AppUsageRepository +import com.getreconnected.reconnected.core.Chatbot +import com.getreconnected.reconnected.core.models.entities.AppUsageInfo +import com.getreconnected.reconnected.core.util.formatTime +import com.getreconnected.reconnected.core.util.getDaysActive +import com.getreconnected.reconnected.core.util.getScreenTimeInMillis +import com.google.firebase.Firebase +import com.google.firebase.ai.Chat +import com.google.firebase.ai.GenerativeModel +import com.google.firebase.ai.ai +import com.google.firebase.ai.type.GenerativeBackend +import com.google.firebase.ai.type.HarmBlockThreshold +import com.google.firebase.ai.type.HarmCategory +import com.google.firebase.ai.type.SafetySetting +import com.google.firebase.ai.type.content + +object ChatManager { + var model: GenerativeModel? = null + + /** + * Initiates a chat session with a predefined conversation starter. + * + * @param name The name of the user starting the chat. This will be included in the initial message. + */ + fun startChat( + name: String?, + appUsageBreakdown: List, + context: Context, + ): Chat { + val initialPrompt = generateInitialPrompt(name, appUsageBreakdown, context) + model = + Firebase + .ai(backend = GenerativeBackend.Companion.googleAI()) + .generativeModel( + modelName = Chatbot.MODEL, + systemInstruction = content { text(initialPrompt) }, + safetySettings = + listOf( + SafetySetting( + HarmCategory.Companion.HARASSMENT, + HarmBlockThreshold.Companion.ONLY_HIGH, + ), + SafetySetting( + HarmCategory.Companion.DANGEROUS_CONTENT, + HarmBlockThreshold.Companion.ONLY_HIGH, + ), + SafetySetting( + HarmCategory.Companion.HATE_SPEECH, + HarmBlockThreshold.Companion.ONLY_HIGH, + ), + SafetySetting( + HarmCategory.Companion.SEXUALLY_EXPLICIT, + HarmBlockThreshold.Companion.ONLY_HIGH, + ), + ), + ) + if (model == null) { + throw Exception("Model is null") + } + + return model!!.startChat( + history = + listOf( + content(role = "model") { text(Chatbot.INITIAL_RESPONSE) }, + ), + ) + } + + /** + * Generates a response by sending the given prompt to a generative model. + * + * @param prompt The input string used to generate a response. + * @return A Chat object containing the generated response text and additional metadata. + */ + suspend fun getResponse(prompt: String): com.getreconnected.reconnected.core.models.entities.Chat { + try { + if (model == null) { + throw Exception("Model is null") + } + val response = model!!.generateContent(prompt) + return com.getreconnected.reconnected.core.models.entities.Chat( + prompt = response.text ?: "error", + bitmap = null, + isFromUser = false, + ) + } catch (e: Exception) { + return com.getreconnected.reconnected.core.models.entities.Chat( + prompt = e.message ?: "error", + bitmap = null, + isFromUser = false, + ) + } + } + + /** + * Generates an initial chatbot prompt customized with the user's information. + * + * @param name The name of the user to be included in the generated prompt. + * @return A string containing the initial chatbot prompt with user-specific details and screen time breakdown. + */ + fun generateInitialPrompt( + name: String?, + appUsageBreakdown: List, + context: Context, + ): String { + // TODO: Integrate the data aggregation system to get this information + val daysSinceStarted = getDaysActive(context = context) + val screenTimeTotal = formatTime(getScreenTimeInMillis(context = context)) + + var prompt = + Chatbot.INITIAL_PROMPT + + """ + | + |The user has this information: + |- Name: ${name ?: "Unable to retrieve name"} + |- Days since started: $daysSinceStarted + | + |The user's screen time for today is $screenTimeTotal. This is a breakdown of the total screen time: + """.trimMargin() + + Log.d("ChatManager", "There are ${appUsageBreakdown.size} apps detected.") + for (app in appUsageBreakdown) { + prompt += "\n- \"${app.appName}\": ${formatTime(app.usageTime)}" + } + + Log.d("ChatManager", "generateInitialPrompt: $prompt") + return prompt + } +} diff --git a/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/core/dataManager/QuotesManager.kt b/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/core/dataManager/QuotesManager.kt new file mode 100644 index 0000000..e88345c --- /dev/null +++ b/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/core/dataManager/QuotesManager.kt @@ -0,0 +1,30 @@ +package com.getreconnected.reconnected.core.dataManager + +import android.content.Context +import com.getreconnected.reconnected.core.FilePaths +import com.getreconnected.reconnected.core.models.entities.Quote +import com.google.gson.Gson + +object QuotesManager { + private var quotes = emptyArray() + + fun getQuotes(context: Context): Array { + if (quotes.isEmpty()) { + load(context) + } + + return quotes + } + + fun load(context: Context) { + val inputStream = context.assets.open(FilePaths.QUOTES) + val size: Int = inputStream.available() + val buffer = ByteArray(size) + inputStream.read(buffer) + inputStream.close() + + val json = String(buffer, charset = Charsets.UTF_8) + val gson = Gson() + quotes = gson.fromJson(json, Array::class.java) + } +} diff --git a/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/core/dataManager/UserManager.kt b/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/core/dataManager/UserManager.kt new file mode 100644 index 0000000..04bd41a --- /dev/null +++ b/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/core/dataManager/UserManager.kt @@ -0,0 +1,71 @@ +package com.getreconnected.reconnected.core.dataManager + +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import com.getreconnected.reconnected.core.models.entities.User +import com.google.firebase.auth.FirebaseAuth +import com.google.firebase.auth.FirebaseUser +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import java.net.URL + +/** + * Manages user-related operations and data within the application. + * + * This object is intended to serve as a central point for handling user information, + * providing functionalities for user management and integration with other system components. + */ +object UserManager { + var isLoggedIn: Boolean = false + private set + var user: User? = null + private set + + /** + * Save user data to local storage. + * + * @param userInfo Firebase user information to be saved. + */ + suspend fun login(userInfo: FirebaseUser) { + val avatarBitmap: Bitmap? = + withContext(Dispatchers.IO) { + try { + userInfo.photoUrl?.let { + val url = URL(it.toString()) + BitmapFactory.decodeStream(url.openStream()) + } + } catch (e: Exception) { + null + } + } + user = + User( + displayName = userInfo.displayName ?: "", + email = userInfo.email ?: "", + avatar = avatarBitmap, + created = 0, + lastSignIn = 0, + ) + isLoggedIn = true + } + + /** + * Remove locally-saved information about the user. + */ + fun logout() { + val firebaseAuth = FirebaseAuth.getInstance() + firebaseAuth.signOut() + user = null + isLoggedIn = false + } + + /** + * Update user information in the Firestore database. + */ + fun update() {} + + /** + * Remove user data from the Firestore database. + */ + fun unregister() {} +} diff --git a/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/core/database/AppLimitDatabase.kt b/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/core/database/AppLimitDatabase.kt new file mode 100644 index 0000000..2a2eeea --- /dev/null +++ b/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/core/database/AppLimitDatabase.kt @@ -0,0 +1,61 @@ +package com.getreconnected.reconnected.core.database + +import android.content.Context +import androidx.room.Database +import androidx.room.Room +import androidx.room.RoomDatabase +import com.getreconnected.reconnected.core.database.dao.AppLimitDao +import com.getreconnected.reconnected.core.database.dao.AppUsageDao +import com.getreconnected.reconnected.core.database.dao.InstalledAppDao +import com.getreconnected.reconnected.core.database.entities.AppLimit +import com.getreconnected.reconnected.core.database.entities.AppUsage +import com.getreconnected.reconnected.core.database.entities.InstalledApp + +/** + * Room database for storing app limits, installed apps, and usage statistics. + * + * This database contains three tables: + * 1. installed_apps - Stores information about all installed applications + * 2. app_limits - Stores user-defined time limits for specific applications + * 3. app_usage - Stores daily usage statistics for apps + */ +@Database( + entities = [InstalledApp::class, AppLimit::class, AppUsage::class], + version = 2, + exportSchema = false, +) +abstract class AppLimitDatabase : RoomDatabase() { + abstract fun installedAppDao(): InstalledAppDao + + abstract fun appLimitDao(): AppLimitDao + + abstract fun appUsageDao(): AppUsageDao + + companion object { + @Volatile private var INSTANCE: AppLimitDatabase? = null + + /** + * Get the singleton instance of the database. + * + * @param context Application context + * @return The database instance + */ + fun getDatabase(context: Context): AppLimitDatabase { + return INSTANCE + ?: synchronized(this) { + val instance = + Room.databaseBuilder( + context.applicationContext, + AppLimitDatabase::class.java, + "app_limit_database", + ) + .fallbackToDestructiveMigration() // For development, + // recreate DB on schema + // changes + .build() + INSTANCE = instance + instance + } + } + } +} diff --git a/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/core/database/dao/AppLimitDao.kt b/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/core/database/dao/AppLimitDao.kt new file mode 100644 index 0000000..679b98c --- /dev/null +++ b/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/core/database/dao/AppLimitDao.kt @@ -0,0 +1,103 @@ +package com.getreconnected.reconnected.core.database.dao + +import androidx.room.Dao +import androidx.room.Delete +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import androidx.room.Update +import com.getreconnected.reconnected.core.database.entities.AppLimit +import kotlinx.coroutines.flow.Flow + +/** + * Data Access Object for AppLimit entities. + * Provides methods to interact with the app_limits table. + */ +@Dao +interface AppLimitDao { + /** + * Get all app limits as a Flow for reactive updates. + */ + @Query("SELECT * FROM app_limits ORDER BY appName ASC") + fun getAllLimits(): Flow> + + /** + * Get all enabled app limits. + */ + @Query("SELECT * FROM app_limits WHERE isEnabled = 1 ORDER BY appName ASC") + fun getEnabledLimits(): Flow> + + /** + * Get a specific app limit by package name. + */ + @Query("SELECT * FROM app_limits WHERE packageName = :packageName") + suspend fun getLimitByPackage(packageName: String): AppLimit? + + /** + * Get a specific app limit by package name as Flow. + */ + @Query("SELECT * FROM app_limits WHERE packageName = :packageName") + fun getLimitByPackageFlow(packageName: String): Flow + + /** + * Insert or update an app limit. + */ + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertLimit(appLimit: AppLimit) + + /** + * Insert multiple app limits. + */ + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertLimits(appLimits: List) + + /** + * Update an existing app limit. + */ + @Update + suspend fun updateLimit(appLimit: AppLimit) + + /** + * Delete an app limit. + */ + @Delete + suspend fun deleteLimit(appLimit: AppLimit) + + /** + * Delete app limit by package name. + */ + @Query("DELETE FROM app_limits WHERE packageName = :packageName") + suspend fun deleteLimitByPackage(packageName: String) + + /** + * Enable or disable a limit. + */ + @Query("UPDATE app_limits SET isEnabled = :isEnabled, updatedAt = :timestamp WHERE packageName = :packageName") + suspend fun setLimitEnabled( + packageName: String, + isEnabled: Boolean, + timestamp: Long = System.currentTimeMillis(), + ) + + /** + * Update the limit time for an app. + */ + @Query("UPDATE app_limits SET limitMillis = :limitMillis, updatedAt = :timestamp WHERE packageName = :packageName") + suspend fun updateLimitTime( + packageName: String, + limitMillis: Long, + timestamp: Long = System.currentTimeMillis(), + ) + + /** + * Check if a limit exists for a package. + */ + @Query("SELECT EXISTS(SELECT 1 FROM app_limits WHERE packageName = :packageName)") + suspend fun hasLimit(packageName: String): Boolean + + /** + * Delete all app limits. + */ + @Query("DELETE FROM app_limits") + suspend fun deleteAllLimits() +} diff --git a/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/core/database/dao/AppUsageDao.kt b/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/core/database/dao/AppUsageDao.kt new file mode 100644 index 0000000..29cd0a0 --- /dev/null +++ b/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/core/database/dao/AppUsageDao.kt @@ -0,0 +1,26 @@ +package com.getreconnected.reconnected.core.database.dao + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import com.getreconnected.reconnected.core.database.entities.AppUsage +import kotlinx.coroutines.flow.Flow + +@Dao +interface AppUsageDao { + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertOrUpdate(appUsage: AppUsage) + + @Query("SELECT * FROM app_usage WHERE date = :date ORDER BY usageMillis DESC") + fun getUsageForDate(date: String): Flow> + + @Query("SELECT * FROM app_usage WHERE packageName = :packageName ORDER BY date DESC LIMIT 30") + fun getUsageForApp(packageName: String): Flow> + + @Query("SELECT * FROM app_usage WHERE date >= :startDate AND date <= :endDate ORDER BY date DESC") + fun getUsageForDateRange(startDate: String, endDate: String): Flow> + + @Query("DELETE FROM app_usage WHERE date < :beforeDate") + suspend fun deleteOldRecords(beforeDate: String) +} diff --git a/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/core/database/dao/InstalledAppDao.kt b/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/core/database/dao/InstalledAppDao.kt new file mode 100644 index 0000000..8a8b493 --- /dev/null +++ b/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/core/database/dao/InstalledAppDao.kt @@ -0,0 +1,85 @@ +package com.getreconnected.reconnected.core.database.dao + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import androidx.room.Update +import com.getreconnected.reconnected.core.database.entities.InstalledApp +import kotlinx.coroutines.flow.Flow + +/** + * Data Access Object for InstalledApp entities. + * Provides methods to interact with the installed_apps table. + */ +@Dao +interface InstalledAppDao { + /** + * Get all installed apps as a Flow. + */ + @Query("SELECT * FROM installed_apps WHERE isInstalled = 1 ORDER BY appName ASC") + fun getAllInstalledApps(): Flow> + + /** + * Get all user apps (non-system apps). + */ + @Query("SELECT * FROM installed_apps WHERE isInstalled = 1 AND isSystemApp = 0 ORDER BY appName ASC") + fun getUserApps(): Flow> + + /** + * Get a specific app by package name. + */ + @Query("SELECT * FROM installed_apps WHERE packageName = :packageName") + suspend fun getAppByPackage(packageName: String): InstalledApp? + + /** + * Insert or update an installed app. + */ + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertApp(app: InstalledApp) + + /** + * Insert multiple apps. + */ + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertApps(apps: List) + + /** + * Update an app. + */ + @Update + suspend fun updateApp(app: InstalledApp) + + /** + * Mark an app as uninstalled. + */ + @Query("UPDATE installed_apps SET isInstalled = 0, lastUpdated = :timestamp WHERE packageName = :packageName") + suspend fun markAsUninstalled( + packageName: String, + timestamp: Long = System.currentTimeMillis(), + ) + + /** + * Delete all apps marked as uninstalled. + */ + @Query("DELETE FROM installed_apps WHERE isInstalled = 0") + suspend fun deleteUninstalledApps() + + /** + * Search apps by name. + */ + @Query("SELECT * FROM installed_apps WHERE appName LIKE '%' || :query || '%' AND isInstalled = 1 ORDER BY appName ASC") + fun searchApps(query: String): Flow> + + /** + * Get count of installed apps. + */ + @Query("SELECT COUNT(*) FROM installed_apps WHERE isInstalled = 1") + suspend fun getInstalledAppCount(): Int + + /** + * Delete all apps. + */ + @Query("DELETE FROM installed_apps") + suspend fun deleteAllApps() +} diff --git a/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/core/database/entities/AppLimit.kt b/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/core/database/entities/AppLimit.kt new file mode 100644 index 0000000..de7ce5a --- /dev/null +++ b/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/core/database/entities/AppLimit.kt @@ -0,0 +1,25 @@ +package com.getreconnected.reconnected.core.database.entities + +import androidx.room.Entity +import androidx.room.PrimaryKey + +/** + * Entity representing a time limit set by the user for a specific application. + * + * @property packageName The unique package name of the application (e.g., "com.example.app") + * @property appName The display name of the application + * @property limitMillis The time limit in milliseconds + * @property isEnabled Whether the limit is currently active + * @property createdAt Timestamp when the limit was created + * @property updatedAt Timestamp when the limit was last updated + */ +@Entity(tableName = "app_limits") +data class AppLimit( + @PrimaryKey + val packageName: String, + val appName: String, + val limitMillis: Long, + val isEnabled: Boolean = true, + val createdAt: Long = System.currentTimeMillis(), + val updatedAt: Long = System.currentTimeMillis(), +) diff --git a/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/core/database/entities/AppUsage.kt b/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/core/database/entities/AppUsage.kt new file mode 100644 index 0000000..cdd6c4f --- /dev/null +++ b/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/core/database/entities/AppUsage.kt @@ -0,0 +1,24 @@ +package com.getreconnected.reconnected.core.database.entities + +import androidx.room.Entity +import androidx.room.PrimaryKey + +/** + * Entity representing daily app usage statistics. + * + * @property id Unique identifier (combination of date and packageName) + * @property packageName The unique package name of the application + * @property appName The display name of the application + * @property date The date in format "yyyy-MM-dd" + * @property usageMillis Total usage time in milliseconds for this day + * @property timestamp When this record was created/updated + */ +@Entity(tableName = "app_usage") +data class AppUsage( + @PrimaryKey val id: String, // format: "yyyy-MM-dd_packageName" + val packageName: String, + val appName: String, + val date: String, // yyyy-MM-dd + val usageMillis: Long, + val timestamp: Long = System.currentTimeMillis(), +) diff --git a/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/core/database/entities/InstalledApp.kt b/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/core/database/entities/InstalledApp.kt new file mode 100644 index 0000000..71edccc --- /dev/null +++ b/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/core/database/entities/InstalledApp.kt @@ -0,0 +1,24 @@ +package com.getreconnected.reconnected.core.database.entities + +import androidx.room.Entity +import androidx.room.PrimaryKey + +/** + * Entity representing an installed application on the device. + * This table stores information about all apps that can potentially have limits set. + * + * @property packageName The unique package name of the application + * @property appName The display name of the application + * @property isSystemApp Whether this is a system application + * @property isInstalled Whether the app is currently installed + * @property lastUpdated Timestamp when this record was last updated + */ +@Entity(tableName = "installed_apps") +data class InstalledApp( + @PrimaryKey + val packageName: String, + val appName: String, + val isSystemApp: Boolean = false, + val isInstalled: Boolean = true, + val lastUpdated: Long = System.currentTimeMillis(), +) diff --git a/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/core/models/Menus.kt b/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/core/models/Menus.kt index 038ef3f..3782f4a 100644 --- a/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/core/models/Menus.kt +++ b/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/core/models/Menus.kt @@ -6,13 +6,14 @@ package com.getreconnected.reconnected.core.models * @property title The title of the menu. */ enum class Menus( - val title: String, + val title: String, ) { Dashboard("Dashboard"), ScreenTimeTracker("Screen Time Tracker"), ScreenTimeLimit("Screen Time Limit"), Calendar("Calendar"), AIAssistant("AI Assistant"), + Settings("Settings"), } /** @@ -22,4 +23,5 @@ enum class Menus( * @return The corresponding menu. */ fun getMenuRoute(route: String): Menus = - Menus.entries.find { it.name == route } ?: throw IllegalArgumentException("Invalid menu route: $route") + Menus.entries.find { it.name == route } + ?: throw IllegalArgumentException("Invalid menu route: $route") diff --git a/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/core/models/AppUsageInfo.kt b/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/core/models/entities/AppUsageInfo.kt similarity index 89% rename from ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/core/models/AppUsageInfo.kt rename to ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/core/models/entities/AppUsageInfo.kt index 544f449..f3627fb 100644 --- a/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/core/models/AppUsageInfo.kt +++ b/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/core/models/entities/AppUsageInfo.kt @@ -1,4 +1,4 @@ -package com.getreconnected.reconnected.core.models +package com.getreconnected.reconnected.core.models.entities import android.graphics.drawable.Drawable diff --git a/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/core/models/Chat.kt b/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/core/models/entities/Chat.kt similarity index 68% rename from ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/core/models/Chat.kt rename to ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/core/models/entities/Chat.kt index 562bdab..56e51ef 100644 --- a/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/core/models/Chat.kt +++ b/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/core/models/entities/Chat.kt @@ -1,4 +1,4 @@ -package com.getreconnected.reconnected.core.models +package com.getreconnected.reconnected.core.models.entities import android.graphics.Bitmap diff --git a/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/core/models/entities/Quote.kt b/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/core/models/entities/Quote.kt new file mode 100644 index 0000000..030cdad --- /dev/null +++ b/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/core/models/entities/Quote.kt @@ -0,0 +1,6 @@ +package com.getreconnected.reconnected.core.models.entities + +data class Quote( + val quote: String, + val author: String, +) diff --git a/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/core/models/entities/User.kt b/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/core/models/entities/User.kt index b8ef79f..40d5d63 100644 --- a/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/core/models/entities/User.kt +++ b/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/core/models/entities/User.kt @@ -1,71 +1,20 @@ package com.getreconnected.reconnected.core.models.entities import android.graphics.Bitmap -import androidx.room.Dao -import androidx.room.Delete -import androidx.room.Entity -import androidx.room.Ignore -import androidx.room.Insert -import androidx.room.PrimaryKey -import androidx.room.Query /** * Represents a user entity in the system. * - * This class defines the main user attributes and maps them to a database entity using Room annotations. - * - * @property uid Unique identifier for the user. - * @property firstName Optional first name of the user. - * @property lastName Optional last name of the user. + * @property displayName Display name of the user. * @property email Email address of the user. * @property created Timestamp indicating when the user was created. * @property lastSignIn Timestamp indicating the most recent login time. * @property avatar Optional avatar image for the user, ignored for database persistence. */ -@Entity data class User( - @PrimaryKey val uid: String, - val firstName: String? = null, - val lastName: String? = null, + val displayName: String? = null, val email: String, val created: Int, val lastSignIn: Int, - // FIXME: Add avatar support - // @Ignore val avatar: Bitmap? = null, + val avatar: Bitmap? = null, ) - -/** - * Data Access Object (DAO) interface for managing User entities in the database. - * - * Provides methods to perform operations such as retrieving all users, finding a user by their unique identifier, - * and deleting a user record from the database. - * - * This interface uses Room annotations to define the corresponding SQL queries for database interactions. - */ -@Dao -interface UserDao { - /** - * Retrieves all user records from the database. - * - * @return A list of all users as entities of type User. - */ - @Query("SELECT * FROM user") - fun getAll(): List - - /** - * Retrieves a user record from the database by their unique identifier (UID). - * - * @param uid The unique identifier of the user to be found. - * @return The user entity matching the specified UID, or null if no user is found. - */ - @Query("SELECT * FROM user WHERE uid = :uid LIMIT 1") - fun findByUid(uid: String): User? - - /** - * Deletes the specified user from the database. - * - * @param user The user entity to be deleted. - */ - @Delete - fun delete(user: User) -} diff --git a/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/core/models/entities/WeeklyScreenTime.kt b/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/core/models/entities/WeeklyScreenTime.kt new file mode 100644 index 0000000..23e4648 --- /dev/null +++ b/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/core/models/entities/WeeklyScreenTime.kt @@ -0,0 +1,8 @@ +package com.getreconnected.reconnected.core.models.entities + +data class WeeklyScreenTime( + val id: Int = 0, + val weekNumber: Int, + val totalTimeMillis: Long, + val topApps: List, +) diff --git a/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/core/repositories/AppLimitRepository.kt b/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/core/repositories/AppLimitRepository.kt new file mode 100644 index 0000000..3498b4e --- /dev/null +++ b/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/core/repositories/AppLimitRepository.kt @@ -0,0 +1,160 @@ +package com.getreconnected.reconnected.core.repositories + +import android.app.Application +import android.content.pm.ApplicationInfo +import android.content.pm.PackageManager +import com.getreconnected.reconnected.core.database.AppLimitDatabase +import com.getreconnected.reconnected.core.database.entities.AppLimit +import com.getreconnected.reconnected.core.database.entities.InstalledApp +import kotlinx.coroutines.flow.Flow + +/** + * Repository for managing app limits. + * Handles business logic for setting, retrieving, and enforcing app usage limits. + */ +class AppLimitRepository( + private val application: Application, +) { + private val database = AppLimitDatabase.getDatabase(application) + private val appLimitDao = database.appLimitDao() + private val installedAppDao = database.installedAppDao() + private val packageManager = application.packageManager + + // Flow for observing all limits + val allLimits: Flow> = appLimitDao.getAllLimits() + + // Flow for observing enabled limits + val enabledLimits: Flow> = appLimitDao.getEnabledLimits() + + // Flow for observing installed apps + val installedApps: Flow> = installedAppDao.getAllInstalledApps() + + // Flow for observing user apps only + val userApps: Flow> = installedAppDao.getUserApps() + + /** + * Sync installed apps from the device to the database. + * This should be called periodically to keep the database up to date. + */ + suspend fun syncInstalledApps() { + val intent = + android.content.Intent( + android.content.Intent.ACTION_MAIN, + null, + ) + intent.addCategory(android.content.Intent.CATEGORY_LAUNCHER) + + val packages = packageManager.queryIntentActivities(intent, 0) + val apps = + packages.mapNotNull { resolveInfo -> + try { + val packageName = resolveInfo.activityInfo.packageName + // Skip our own app + if (packageName == application.packageName) return@mapNotNull null + + val appInfo = packageManager.getApplicationInfo(packageName, 0) + val appName = packageManager.getApplicationLabel(appInfo).toString() + val isSystemApp = (appInfo.flags and ApplicationInfo.FLAG_SYSTEM) != 0 + + InstalledApp( + packageName = packageName, + appName = appName, + isSystemApp = isSystemApp, + isInstalled = true, + lastUpdated = System.currentTimeMillis(), + ) + } catch (e: PackageManager.NameNotFoundException) { + null + } + } + + installedAppDao.insertApps(apps) + } + + /** + * Set or update a limit for an app. + * + * @param packageName The package name of the app + * @param appName The display name of the app + * @param limitMillis The limit in milliseconds + * @param isEnabled Whether the limit should be enabled + */ + suspend fun setLimit( + packageName: String, + appName: String, + limitMillis: Long, + isEnabled: Boolean = true, + ) { + val limit = + AppLimit( + packageName = packageName, + appName = appName, + limitMillis = limitMillis, + isEnabled = isEnabled, + updatedAt = System.currentTimeMillis(), + ) + appLimitDao.insertLimit(limit) + } + + /** + * Get a limit for a specific app. + */ + suspend fun getLimit(packageName: String): AppLimit? { + return appLimitDao.getLimitByPackage(packageName) + } + + /** + * Get a limit as Flow for reactive updates. + */ + fun getLimitFlow(packageName: String): Flow { + return appLimitDao.getLimitByPackageFlow(packageName) + } + + /** + * Update the limit time for an app. + */ + suspend fun updateLimitTime( + packageName: String, + limitMillis: Long, + ) { + appLimitDao.updateLimitTime(packageName, limitMillis) + } + + /** + * Enable or disable a limit. + */ + suspend fun setLimitEnabled( + packageName: String, + isEnabled: Boolean, + ) { + appLimitDao.setLimitEnabled(packageName, isEnabled) + } + + /** + * Delete a limit. + */ + suspend fun deleteLimit(packageName: String) { + appLimitDao.deleteLimitByPackage(packageName) + } + + /** + * Check if an app has a limit set. + */ + suspend fun hasLimit(packageName: String): Boolean { + return appLimitDao.hasLimit(packageName) + } + + /** + * Get an installed app by package name. + */ + suspend fun getInstalledApp(packageName: String): InstalledApp? { + return installedAppDao.getAppByPackage(packageName) + } + + /** + * Search installed apps by name. + */ + fun searchApps(query: String): Flow> { + return installedAppDao.searchApps(query) + } +} diff --git a/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/core/repository/FirebaseUsageSyncRepository.kt b/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/core/repository/FirebaseUsageSyncRepository.kt new file mode 100644 index 0000000..d8a8eed --- /dev/null +++ b/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/core/repository/FirebaseUsageSyncRepository.kt @@ -0,0 +1,269 @@ +package com.getreconnected.reconnected.core.repository + +import android.util.Log +import com.getreconnected.reconnected.core.database.AppLimitDatabase +import com.getreconnected.reconnected.core.database.entities.AppUsage +import com.google.firebase.auth.FirebaseAuth +import com.google.firebase.firestore.FirebaseFirestore +import java.text.SimpleDateFormat +import java.util.Calendar +import java.util.Locale +import java.util.TimeZone +import kotlinx.coroutines.tasks.await + +/** Repository for syncing app usage data to Firebase Firestore. */ +class FirebaseUsageSyncRepository( + private val database: AppLimitDatabase, + private val firestore: FirebaseFirestore = FirebaseFirestore.getInstance(), + private val auth: FirebaseAuth = FirebaseAuth.getInstance(), +) { + companion object { + private const val TAG = "FirebaseUsageSync" + private const val COLLECTION_USERS = "users" + private const val COLLECTION_APP_USAGE = "appUsage" + } + + /** + * Upload today's app usage data to Firestore. Data is stored in: + * users/{userId}/appUsage/{date}/{packageName} + */ + suspend fun syncTodayUsage(usageData: Map>) { + Log.d(TAG, "syncTodayUsage: Starting sync for ${usageData.size} apps") + + val userId = auth.currentUser?.uid + if (userId == null) { + Log.w(TAG, "syncTodayUsage: User not authenticated, skipping sync") + return + } + Log.d(TAG, "syncTodayUsage: Authenticated user found (uid length: ${userId.length})") + + val today = getTodayDateString() + Log.d(TAG, "syncTodayUsage: Syncing data for date: $today") + + val batch = firestore.batch() + Log.d(TAG, "syncTodayUsage: Created Firestore batch") + + try { + var processedCount = 0 + usageData.forEach { (packageName, data) -> + val (appName, usageMillis) = data + Log.v( + TAG, + "syncTodayUsage: Processing app #${processedCount + 1}: $appName (${usageMillis}ms)" + ) + + // Save to local database + val appUsage = + AppUsage( + id = "${today}_$packageName", + packageName = packageName, + appName = appName, + date = today, + usageMillis = usageMillis, + ) + database.appUsageDao().insertOrUpdate(appUsage) + Log.v(TAG, "syncTodayUsage: Saved to local DB: $appName") + + // Prepare Firestore batch update + val docRef = + firestore + .collection(COLLECTION_USERS) + .document(userId) + .collection(COLLECTION_APP_USAGE) + .document(today) + .collection("apps") + .document(packageName) + + val usageMap = + hashMapOf( + "packageName" to packageName, + "appName" to appName, + "usageMillis" to usageMillis, + "date" to today, + "timestamp" to System.currentTimeMillis(), + ) + + batch.set(docRef, usageMap) + Log.v(TAG, "syncTodayUsage: Added to batch: $appName") + processedCount++ + } + + // Commit the batch + Log.d(TAG, "syncTodayUsage: Committing batch with $processedCount apps to Firestore") + batch.commit().await() + Log.i( + TAG, + "syncTodayUsage: ✓ Successfully synced ${usageData.size} app usage records for $today" + ) + } catch (e: Exception) { + Log.e(TAG, "syncTodayUsage: ✗ Error syncing usage data to Firestore: ${e.message}", e) + } + } + + /** + * Upload the last 7 days of app usage data to Firestore. This is useful for initial sync to + * provide historical context for CO2e calculations. + */ + suspend fun syncLast7DaysUsage(usageDataByDate: Map>>) { + Log.d(TAG, "syncLast7DaysUsage: Starting sync for ${usageDataByDate.size} days") + + val userId = auth.currentUser?.uid + if (userId == null) { + Log.w(TAG, "syncLast7DaysUsage: User not authenticated, skipping sync") + return + } + + try { + var totalApps = 0 + usageDataByDate.forEach { (date, usageData) -> + if (usageData.isEmpty()) { + Log.d(TAG, "syncLast7DaysUsage: No data for $date, skipping") + return@forEach + } + + Log.d(TAG, "syncLast7DaysUsage: Syncing ${usageData.size} apps for $date") + + // Process in batches of 500 (Firestore batch limit) + val appList = usageData.toList() + appList.chunked(500).forEach { chunk -> + val batch = firestore.batch() + + chunk.forEach { (packageName, data) -> + val (appName, usageMillis) = data + + // Save to local database + val appUsage = + AppUsage( + id = "${date}_$packageName", + packageName = packageName, + appName = appName, + date = date, + usageMillis = usageMillis, + ) + database.appUsageDao().insertOrUpdate(appUsage) + + // Prepare Firestore batch update + val docRef = + firestore + .collection(COLLECTION_USERS) + .document(userId) + .collection(COLLECTION_APP_USAGE) + .document(date) + .collection("apps") + .document(packageName) + + val usageMap = + hashMapOf( + "packageName" to packageName, + "appName" to appName, + "usageMillis" to usageMillis, + "date" to date, + "timestamp" to System.currentTimeMillis(), + ) + + batch.set(docRef, usageMap) + totalApps++ + } + + // Commit the batch + batch.commit().await() + Log.d( + TAG, + "syncLast7DaysUsage: Committed batch of ${chunk.size} apps for $date" + ) + } + } + + Log.i( + TAG, + "syncLast7DaysUsage: ✓ Successfully synced $totalApps app usage records for ${usageDataByDate.size} days" + ) + } catch (e: Exception) { + Log.e(TAG, "syncLast7DaysUsage: ✗ Error syncing historical usage data: ${e.message}", e) + } + } + + /** Upload a single app's usage for today. */ + suspend fun syncAppUsage(packageName: String, appName: String, usageMillis: Long) { + Log.d(TAG, "syncAppUsage: Syncing single app: $appName (${usageMillis}ms)") + + val userId = auth.currentUser?.uid + if (userId == null) { + Log.w(TAG, "syncAppUsage: User not authenticated, skipping sync") + return + } + + val today = getTodayDateString() + Log.v(TAG, "syncAppUsage: Date: $today") + + try { + // Save to local database + val appUsage = + AppUsage( + id = "${today}_$packageName", + packageName = packageName, + appName = appName, + date = today, + usageMillis = usageMillis, + ) + database.appUsageDao().insertOrUpdate(appUsage) + + // Upload to Firestore + val docRef = + firestore + .collection(COLLECTION_USERS) + .document(userId) + .collection(COLLECTION_APP_USAGE) + .document(today) + .collection("apps") + .document(packageName) + + val usageMap = + hashMapOf( + "packageName" to packageName, + "appName" to appName, + "usageMillis" to usageMillis, + "date" to today, + "timestamp" to System.currentTimeMillis(), + ) + + Log.v(TAG, "syncAppUsage: Writing to Firestore...") + docRef.set(usageMap).await() + Log.i(TAG, "syncAppUsage: ✓ Synced usage for $appName: ${usageMillis}ms") + } catch (e: Exception) { + Log.e(TAG, "syncAppUsage: ✗ Error syncing usage for $packageName: ${e.message}", e) + } + } + + private fun getTodayDateString(): String { + val calendar = Calendar.getInstance(TimeZone.getTimeZone("UTC")) + val dateFormat = + SimpleDateFormat("yyyy-MM-dd", Locale.US).apply { + timeZone = TimeZone.getTimeZone("UTC") + } + return dateFormat.format(calendar.time) + } + + /** + * Get date strings for the last N days (including today) in UTC format. + * @param days Number of days to retrieve (default 7) + * @return List of date strings in "yyyy-MM-dd" format, ordered from oldest to newest + */ + fun getLastNDaysDateStrings(days: Int = 7): List { + val dateFormat = + SimpleDateFormat("yyyy-MM-dd", Locale.US).apply { + timeZone = TimeZone.getTimeZone("UTC") + } + val calendar = Calendar.getInstance(TimeZone.getTimeZone("UTC")) + val dates = mutableListOf() + + // Start from (days - 1) days ago to include today + for (i in (days - 1) downTo 0) { + val date = calendar.clone() as Calendar + date.add(Calendar.DAY_OF_YEAR, -i) + dates.add(dateFormat.format(date.time)) + } + + return dates + } +} diff --git a/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/core/util/AppLimitPermissionHelper.kt b/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/core/util/AppLimitPermissionHelper.kt new file mode 100644 index 0000000..7a9a6a2 --- /dev/null +++ b/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/core/util/AppLimitPermissionHelper.kt @@ -0,0 +1,89 @@ +package com.getreconnected.reconnected.core.util + +import android.app.NotificationManager +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.os.Build +import android.provider.Settings + +/** + * Utility functions for managing app limit permissions. + */ +object AppLimitPermissionHelper { + /** + * Check if the app can draw overlays (SYSTEM_ALERT_WINDOW permission). + * This is required to show blocking overlay over apps. + */ + fun canDrawOverlays(context: Context): Boolean { + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + Settings.canDrawOverlays(context) + } else { + true // Older Android versions don't need explicit permission + } + } + + /** + * Open settings to allow drawing overlays. + */ + fun openOverlaySettings(context: Context) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + val intent = + Intent( + Settings.ACTION_MANAGE_OVERLAY_PERMISSION, + Uri.parse("package:${context.packageName}"), + ) + intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK + context.startActivity(intent) + } + } + + /** + * Check if the app can show full-screen intents (needed for blocking popup). + * This is required on Android 14+ for automatic popup display. + */ + fun canShowFullScreenIntent(context: Context): Boolean { + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + val notificationManager = + context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + notificationManager.canUseFullScreenIntent() + } else { + true // Older Android versions don't need this permission + } + } + + /** + * Open settings to allow full-screen intents. + */ + fun openFullScreenIntentSettings(context: Context) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + val intent = + Intent( + Settings.ACTION_MANAGE_APP_USE_FULL_SCREEN_INTENT, + Uri.parse("package:${context.packageName}"), + ) + intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK + context.startActivity(intent) + } + } + + /** + * Check if all required permissions for app limiting are granted. + */ + fun hasAllRequiredPermissions(context: Context): Boolean { + val hasUsageAccess = hasUsageStatsPermission(context) + val canDrawOverlay = canDrawOverlays(context) + return hasUsageAccess && canDrawOverlay + } + + private fun hasUsageStatsPermission(context: Context): Boolean { + val appOps = context.getSystemService(Context.APP_OPS_SERVICE) as android.app.AppOpsManager + val mode = + appOps.unsafeCheckOpNoThrow( + android.app.AppOpsManager.OPSTR_GET_USAGE_STATS, + android.os.Process.myUid(), + context.packageName, + ) + return mode == android.app.AppOpsManager.MODE_ALLOWED + } +} diff --git a/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/core/util/AppLimitPreferences.kt b/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/core/util/AppLimitPreferences.kt new file mode 100644 index 0000000..726c885 --- /dev/null +++ b/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/core/util/AppLimitPreferences.kt @@ -0,0 +1,33 @@ +package com.getreconnected.reconnected.core.util + +import android.content.Context +import android.content.SharedPreferences + +/** + * Utility object for managing app limit monitoring preferences. + */ +object AppLimitPreferences { + private const val PREFS_NAME = "app_limit_prefs" + private const val KEY_MONITORING_ENABLED = "monitoring_enabled" + + private fun getPreferences(context: Context): SharedPreferences { + return context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + } + + /** + * Check if app limit monitoring is enabled. + */ + fun isMonitoringEnabled(context: Context): Boolean { + return getPreferences(context).getBoolean(KEY_MONITORING_ENABLED, true) + } + + /** + * Set monitoring enabled state. + */ + fun setMonitoringEnabled( + context: Context, + enabled: Boolean, + ) { + getPreferences(context).edit().putBoolean(KEY_MONITORING_ENABLED, enabled).apply() + } +} diff --git a/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/legacy/data/GetDailyStats.kt b/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/core/util/ScreenTime.kt similarity index 63% rename from ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/legacy/data/GetDailyStats.kt rename to ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/core/util/ScreenTime.kt index 6b0793d..8ce689f 100644 --- a/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/legacy/data/GetDailyStats.kt +++ b/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/core/util/ScreenTime.kt @@ -1,22 +1,31 @@ -package com.getreconnected.reconnected.legacy.data +package com.getreconnected.reconnected.core.util import android.app.usage.UsageEvents import android.app.usage.UsageStatsManager import android.content.Context -import com.getreconnected.reconnected.core.util.hasUsageStatsPermission import java.util.Calendar import java.util.concurrent.TimeUnit -fun getDaysActive(context: Context): Long = - try { - val firstInstallTime = context.packageManager.getPackageInfo(context.packageName, 0).firstInstallTime - val currentTime = System.currentTimeMillis() - val diff = currentTime - firstInstallTime - TimeUnit.MILLISECONDS.toDays(diff) + 1 - } catch (e: Exception) { - 1L - } +/** + * Calculates the number of days the application has been active since its first installation. + * + * @param context The application context used to retrieve package information. + * @return The number of days the application has been active (installation date inclusive). + */ +fun getDaysActive(context: Context): Long { + val firstInstallTime = context.packageManager.getPackageInfo(context.packageName, 0).firstInstallTime + val currentTime = System.currentTimeMillis() + val diff = currentTime - firstInstallTime + return TimeUnit.MILLISECONDS.toDays(diff) +} +/** + * Retrieves the total screen time in milliseconds for the current day. + * + * @param context The context used to access system services like `UsageStatsManager`. + * @return The total screen time in milliseconds for the current day. If the app does not + * have the required usage stats permission, it returns 0L. + */ fun getScreenTimeInMillis(context: Context): Long { if (!hasUsageStatsPermission(context)) return 0L @@ -61,17 +70,3 @@ fun getScreenTimeInMillis(context: Context): Long { return totalScreenTime } - -// Formats the millis into h/m -fun formatScreenTime(millis: Long): String { - if (millis <= 0L) return "0m" - - val hours = TimeUnit.MILLISECONDS.toHours(millis) - val minutes = TimeUnit.MILLISECONDS.toMinutes(millis) % 60 - - return when { - hours > 0 -> "${hours}h ${minutes}m" - minutes > 0 -> "${minutes}m" - else -> "< 1m" - } -} diff --git a/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/core/util/TimeManagement.kt b/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/core/util/TimeManagement.kt new file mode 100644 index 0000000..cca45e3 --- /dev/null +++ b/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/core/util/TimeManagement.kt @@ -0,0 +1,32 @@ +package com.getreconnected.reconnected.core.util + +/** + * Formats the given time in milliseconds to a human-readable string. + * + * @param t The time in milliseconds to format. + * @return A string representing the formatted time in the format "h m s". + */ +fun formatTime( + t: Long, + strip: Boolean = false, +): String { + val seconds = (t / 1000) % 60 + val minutes = (t / (1000 * 60)) % 60 + val hours = (t / (1000 * 60 * 60)) % 24 + val s = "${seconds}s" + return when { + hours > 0 -> + if (strip) { + "${hours}h ${minutes}m" + } else { + "${hours}h ${minutes}m $s" + } + minutes > 0 -> + if (strip) { + "${minutes}m" + } else { + "${minutes}m $s" + } + else -> s + } +} diff --git a/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/core/viewModels/ScreenTimeLimitViewModel.kt b/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/core/viewModels/ScreenTimeLimitViewModel.kt new file mode 100644 index 0000000..4c8c842 --- /dev/null +++ b/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/core/viewModels/ScreenTimeLimitViewModel.kt @@ -0,0 +1,125 @@ +package com.getreconnected.reconnected.core.viewModels + +import android.app.Application +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.viewModelScope +import com.getreconnected.reconnected.core.AppUsageRepository +import com.getreconnected.reconnected.core.database.entities.AppLimit +import com.getreconnected.reconnected.core.models.entities.AppUsageInfo +import com.getreconnected.reconnected.core.repositories.AppLimitRepository +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch + +/** Data class combining app usage info with limit information. */ +data class AppUsageWithLimit( + val usageInfo: AppUsageInfo, + val limit: AppLimit?, +) + +/** + * ViewModel class for tracking and managing app screen time usage and limits. + * + * This ViewModel interacts with the AppUsageRepository and AppLimitRepository to fetch and provide + * the app usage statistics and limits in the form of StateFlows. It is responsible for managing the + * lifecycle-conscious asynchronous data updates to the associated UI components. + * + * @constructor Creates an instance of ScreenTimeLimitViewModel. + * @param application The Application context required for the repositories. + */ +class ScreenTimeLimitViewModel( + application: Application, +) : AndroidViewModel(application) { + private val appUsageRepository = AppUsageRepository(application) + private val appLimitRepository = AppLimitRepository(application) + + private val _appUsageStats = MutableStateFlow>(emptyList()) + val appUsageStats: StateFlow> = _appUsageStats + + // Observe all installed apps + val installedApps = appLimitRepository.installedApps + + // Observe all app limits + val appLimits: StateFlow> = + appLimitRepository.allLimits.stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5000), + initialValue = emptyList(), + ) + + // Combine usage stats with limits + val appUsageWithLimits: StateFlow> = + combine(_appUsageStats, appLimits) { usageList, limitList -> + usageList.map { usage -> + val limit = limitList.find { it.packageName == usage.packageName } + AppUsageWithLimit(usage, limit) + } + } + .stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5000), + initialValue = emptyList(), + ) + + init { + // Sync installed apps on initialization + viewModelScope.launch { appLimitRepository.syncInstalledApps() } + } + + fun loadUsageStats() { + viewModelScope.launch { _appUsageStats.value = appUsageRepository.getDailyUsageStats() } + } + + /** + * Set a time limit for an app. + * + * @param packageName The package name of the app + * @param appName The display name of the app + * @param hours Number of hours + * @param minutes Number of minutes + */ + fun setLimit( + packageName: String, + appName: String, + hours: Int, + minutes: Int, + ) { + viewModelScope.launch { + val limitMillis = (hours * 60 * 60 * 1000L) + (minutes * 60 * 1000L) + appLimitRepository.setLimit(packageName, appName, limitMillis, isEnabled = true) + } + } + + /** Update an existing limit. */ + fun updateLimit( + packageName: String, + hours: Int, + minutes: Int, + ) { + viewModelScope.launch { + val limitMillis = (hours * 60 * 60 * 1000L) + (minutes * 60 * 1000L) + appLimitRepository.updateLimitTime(packageName, limitMillis) + } + } + + /** Enable or disable a limit. */ + fun toggleLimit( + packageName: String, + isEnabled: Boolean, + ) { + viewModelScope.launch { appLimitRepository.setLimitEnabled(packageName, isEnabled) } + } + + /** Delete a limit. */ + fun deleteLimit(packageName: String) { + viewModelScope.launch { appLimitRepository.deleteLimit(packageName) } + } + + /** Get a specific limit. */ + suspend fun getLimit(packageName: String): AppLimit? { + return appLimitRepository.getLimit(packageName) + } +} diff --git a/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/core/viewModels/ScreenTimeTrackerViewModel.kt b/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/core/viewModels/ScreenTimeTrackerViewModel.kt index 1cdc81a..cfa8383 100644 --- a/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/core/viewModels/ScreenTimeTrackerViewModel.kt +++ b/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/core/viewModels/ScreenTimeTrackerViewModel.kt @@ -4,10 +4,13 @@ import android.app.Application import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.viewModelScope import com.getreconnected.reconnected.core.AppUsageRepository -import com.getreconnected.reconnected.core.models.AppUsageInfo +import com.getreconnected.reconnected.core.models.entities.AppUsageInfo import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch +import java.time.LocalDate +import java.util.Calendar /** * ViewModel class for tracking and managing app screen time usage. @@ -28,9 +31,46 @@ class ScreenTimeTrackerViewModel( private val _appUsageStats = MutableStateFlow>(emptyList()) val appUsageStats: StateFlow> = _appUsageStats + private val _weeklyUsageStats = MutableStateFlow>(emptyMap()) + val weeklyUsageStats: StateFlow> = _weeklyUsageStats.asStateFlow() + + private val _selectedDate = MutableStateFlow(null) + val selectedDate: StateFlow = _selectedDate.asStateFlow() + fun loadUsageStats() { viewModelScope.launch { _appUsageStats.value = appUsageRepository.getDailyUsageStats() } } + + fun loadWeeklyUsageStats() { + viewModelScope.launch { + _weeklyUsageStats.value = appUsageRepository.getWeeklyUsageStats() + } + } + + /** + * Loads usage statistics for a specific date. + * @param date The date to load usage statistics for. + */ + fun loadUsageStatsForDate(date: LocalDate) { + viewModelScope.launch { + _selectedDate.value = date + val calendar = + Calendar.getInstance().apply { + set(date.year, date.monthValue - 1, date.dayOfMonth) + } + _appUsageStats.value = appUsageRepository.getUsageStatsForDate(calendar) + } + } + + /** + * Clears the selected date and loads today's usage statistics. + */ + fun clearSelectedDate() { + viewModelScope.launch { + _selectedDate.value = null + loadUsageStats() + } + } } diff --git a/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/core/viewModels/SettingsViewModel.kt b/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/core/viewModels/SettingsViewModel.kt new file mode 100644 index 0000000..9e83efd --- /dev/null +++ b/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/core/viewModels/SettingsViewModel.kt @@ -0,0 +1,144 @@ +package com.getreconnected.reconnected.core.viewModels + +import android.content.Context +import android.content.Intent +import android.util.Log +import androidx.lifecycle.ViewModel +import com.getreconnected.reconnected.activities.MainActivity +import com.getreconnected.reconnected.core.database.AppLimitDatabase +import com.getreconnected.reconnected.services.AppLimitMonitorService +import com.google.firebase.auth.FirebaseAuth +import com.google.firebase.auth.FirebaseAuthRecentLoginRequiredException +import com.google.firebase.firestore.FirebaseFirestore +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.tasks.await +import kotlinx.coroutines.withContext + +class SettingsViewModel : ViewModel() { + private val auth = FirebaseAuth.getInstance() + private val firestore = FirebaseFirestore.getInstance() + + companion object { + private const val TAG = "SettingsViewModel" + } + + fun syncNow() { + Log.d(TAG, "syncNow: User initiated manual sync") + AppLimitMonitorService.syncNow() + Log.d(TAG, "syncNow: Sync request sent to service") + } + + suspend fun deleteAccount(context: Context) { + val userId = auth.currentUser?.uid + if (userId == null) { + Log.w(TAG, "deleteAccount: No user authenticated, cannot delete account") + return + } + + Log.i( + TAG, + "deleteAccount: Starting account deletion process (uid length: ${userId.length})" + ) + + try { + // Delete all Firestore data + Log.d(TAG, "deleteAccount: Step 1 - Deleting Firestore data") + deleteUserDataFromFirestore(userId) + Log.i(TAG, "deleteAccount: ✓ Firestore data deleted") + + // Clear local database + Log.d(TAG, "deleteAccount: Step 2 - Clearing local database") + val database = AppLimitDatabase.getDatabase(context) + withContext(Dispatchers.IO) { database.clearAllTables() } + Log.i(TAG, "deleteAccount: ✓ Local database cleared") + + // Delete Firebase Auth account + Log.d(TAG, "deleteAccount: Step 3 - Deleting Firebase Auth account") + try { + auth.currentUser?.delete()?.await() + Log.i(TAG, "deleteAccount: ✓ Firebase Auth account deleted") + } catch (e: FirebaseAuthRecentLoginRequiredException) { + Log.w(TAG, "deleteAccount: Recent authentication required for account deletion") + Log.w( + TAG, + "deleteAccount: Skipping Firebase Auth account deletion - user will need to log in and delete again" + ) + // Continue with sign out - data is already deleted + } + + // Sign out + auth.signOut() + Log.d(TAG, "deleteAccount: Signed out from Firebase") + + // Navigate back to login + Log.d(TAG, "deleteAccount: Navigating to login screen") + val intent = Intent(context, MainActivity::class.java) + intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK + context.startActivity(intent) + Log.i(TAG, "deleteAccount: ✓ Account deletion completed successfully") + } catch (e: Exception) { + Log.e(TAG, "deleteAccount: ✗ Account deletion failed: ${e.message}", e) + throw Exception("Failed to delete account: ${e.message}", e) + } + } + + private suspend fun deleteUserDataFromFirestore(userId: String) { + try { + Log.d(TAG, "deleteUserDataFromFirestore: Starting Firestore deletion") + + // Delete all app usage data + val usageRef = firestore.collection("users").document(userId).collection("appUsage") + Log.v(TAG, "deleteUserDataFromFirestore: Querying appUsage collection") + + val usageDocs = usageRef.get().await() + Log.d( + TAG, + "deleteUserDataFromFirestore: Found ${usageDocs.size()} date documents to delete" + ) + + val batch = firestore.batch() + var totalAppDocsDeleted = 0 + + for (dateDoc in usageDocs.documents) { + Log.v(TAG, "deleteUserDataFromFirestore: Processing date: ${dateDoc.id}") + + // Delete all apps subcollection for each date + val appsRef = dateDoc.reference.collection("apps") + val appDocs = appsRef.get().await() + Log.v( + TAG, + "deleteUserDataFromFirestore: Found ${appDocs.size()} app documents for date ${dateDoc.id}" + ) + + for (appDoc in appDocs.documents) { + batch.delete(appDoc.reference) + totalAppDocsDeleted++ + } + + // Delete the date document + batch.delete(dateDoc.reference) + } + + Log.d( + TAG, + "deleteUserDataFromFirestore: Deleting ${totalAppDocsDeleted} app documents and ${usageDocs.size()} date documents" + ) + + // Delete the user document + batch.delete(firestore.collection("users").document(userId)) + Log.d(TAG, "deleteUserDataFromFirestore: Added user document to deletion batch") + + // Commit all deletes + Log.d(TAG, "deleteUserDataFromFirestore: Committing batch deletion") + batch.commit().await() + Log.i(TAG, "deleteUserDataFromFirestore: ✓ Successfully deleted all Firestore data") + } catch (e: Exception) { + Log.e( + TAG, + "deleteUserDataFromFirestore: ✗ Failed to delete Firestore data: ${e.message}", + e + ) + throw Exception("Failed to delete Firestore data: ${e.message}", e) + } + } +} diff --git a/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/legacy/data/Converters.kt b/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/legacy/data/Converters.kt deleted file mode 100644 index da3e5dc..0000000 --- a/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/legacy/data/Converters.kt +++ /dev/null @@ -1,11 +0,0 @@ -package com.getreconnected.reconnected.legacy.data - -import androidx.room.TypeConverter - -class Converters { - @TypeConverter - fun fromList(value: List): String = value.joinToString(",") - - @TypeConverter - fun toList(value: String): List = value.split(",") -} diff --git a/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/legacy/data/GetWeeklyStats.kt b/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/legacy/data/GetWeeklyStats.kt deleted file mode 100644 index b1b7f61..0000000 --- a/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/legacy/data/GetWeeklyStats.kt +++ /dev/null @@ -1,65 +0,0 @@ -package com.getreconnected.reconnected.legacy.data - -import android.app.usage.UsageStats -import android.app.usage.UsageStatsManager -import android.content.Context -import android.graphics.drawable.Drawable -import java.util.concurrent.TimeUnit - -fun getWeeklyUsageStats(context: Context): List { - val usageStatsManager = - context.getSystemService(Context.USAGE_STATS_SERVICE) as UsageStatsManager - - val endTime = System.currentTimeMillis() - val startTime = endTime - TimeUnit.DAYS.toMillis(7) // last 7 days - - return usageStatsManager.queryUsageStats( - UsageStatsManager.INTERVAL_DAILY, - startTime, - endTime, - ) ?: emptyList() -} - -fun getAppInfo( - context: Context, - packageName: String, -): Pair? = - try { - val pm = context.packageManager - val appInfo = pm.getApplicationInfo(packageName, 0) - val name = pm.getApplicationLabel(appInfo).toString() - val icon = pm.getApplicationIcon(packageName) - name to icon - } catch (e: Exception) { - null - } - -fun getWeekNumber(installTime: Long): Int { - val diff = System.currentTimeMillis() - installTime - return (diff / TimeUnit.DAYS.toMillis(7)).toInt() + 1 -} - -suspend fun saveWeeklyUsage( - context: Context, - dao: WeeklyScreenTimeDao, - installTime: Long, -) { - val stats = getWeeklyUsageStats(context) - val totalTime = stats.sumOf { it.totalTimeInForeground } - - val topApps = - stats - .sortedByDescending { it.totalTimeInForeground } - .take(5) - .map { it.packageName } - - val weekNumber = getWeekNumber(installTime) - - dao.insertOrUpdate( - WeeklyScreenTime( - weekNumber = weekNumber, - totalTimeMillis = totalTime, - topApps = topApps, - ), - ) -} diff --git a/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/legacy/data/WeeklyScreenTime.kt b/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/legacy/data/WeeklyScreenTime.kt deleted file mode 100644 index c6f980f..0000000 --- a/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/legacy/data/WeeklyScreenTime.kt +++ /dev/null @@ -1,12 +0,0 @@ -package com.getreconnected.reconnected.legacy.data - -import androidx.room.Entity -import androidx.room.PrimaryKey - -@Entity(tableName = "weekly_screen_time") -data class WeeklyScreenTime( - @PrimaryKey(autoGenerate = true) val id: Int = 0, - val weekNumber: Int, - val totalTimeMillis: Long, - val topApps: List, -) diff --git a/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/legacy/data/WeeklyScreenTimeDao.kt b/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/legacy/data/WeeklyScreenTimeDao.kt deleted file mode 100644 index f3f1272..0000000 --- a/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/legacy/data/WeeklyScreenTimeDao.kt +++ /dev/null @@ -1,19 +0,0 @@ -package com.getreconnected.reconnected.legacy.data - -import androidx.room.Dao -import androidx.room.Insert -import androidx.room.OnConflictStrategy -import androidx.room.Query -import kotlinx.coroutines.flow.Flow - -@Dao -interface WeeklyScreenTimeDao { - @Query("SELECT * FROM weekly_screen_time ORDER BY weekNumber ASC") - fun getAll(): Flow> - - @Query("SELECT * FROM weekly_screen_time WHERE weekNumber = :weekNumber LIMIT 1") - suspend fun getWeek(weekNumber: Int): WeeklyScreenTime? - - @Insert(onConflict = OnConflictStrategy.REPLACE) - suspend fun insertOrUpdate(weeklyScreenTime: WeeklyScreenTime) -} diff --git a/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/services/AppBlockOverlayService.kt b/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/services/AppBlockOverlayService.kt new file mode 100644 index 0000000..b0e9570 --- /dev/null +++ b/ReconnectED-Android/app/src/main/java/com/getreconnected/reconnected/services/AppBlockOverlayService.kt @@ -0,0 +1,132 @@ +package com.getreconnected.reconnected.services + +import android.app.Service +import android.content.Context +import android.content.Intent +import android.graphics.PixelFormat +import android.os.Build +import android.os.IBinder +import android.util.Log +import android.view.Gravity +import android.view.LayoutInflater +import android.view.View +import android.view.WindowManager +import android.widget.Button +import android.widget.TextView +import com.getreconnected.reconnected.R + +/** + * Service that displays a blocking overlay window over apps that have exceeded their limit. + * This uses SYSTEM_ALERT_WINDOW permission to show an overlay that can't be easily dismissed. + */ +class AppBlockOverlayService : Service() { + private var windowManager: WindowManager? = null + private var overlayView: View? = null + + companion object { + private const val TAG = "AppBlockOverlay" + const val EXTRA_APP_NAME = "app_name" + const val EXTRA_LIMIT_MILLIS = "limit_millis" + + fun show(context: Context, appName: String, limitMillis: Long) { + val intent = Intent(context, AppBlockOverlayService::class.java).apply { + putExtra(EXTRA_APP_NAME, appName) + putExtra(EXTRA_LIMIT_MILLIS, limitMillis) + } + context.startService(intent) + } + + fun hide(context: Context) { + val intent = Intent(context, AppBlockOverlayService::class.java) + context.stopService(intent) + } + } + + override fun onBind(intent: Intent?): IBinder? = null + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + val appName = intent?.getStringExtra(EXTRA_APP_NAME) ?: "This app" + val limitMillis = intent?.getLongExtra(EXTRA_LIMIT_MILLIS, 0) ?: 0 + + showOverlay(appName, limitMillis) + + return START_NOT_STICKY + } + + private fun showOverlay(appName: String, limitMillis: Long) { + // Remove any existing overlay first + removeOverlay() + + windowManager = getSystemService(Context.WINDOW_SERVICE) as WindowManager + + // Inflate the overlay layout + overlayView = LayoutInflater.from(this).inflate(R.layout.app_block_overlay, null) + + // Set up the overlay view + overlayView?.apply { + findViewById(R.id.blockedAppName)?.text = appName + + val limitMinutes = limitMillis / (1000 * 60) + findViewById(R.id.limitText)?.text = + "You've reached your ${limitMinutes}min limit for today" + + findViewById