commit 6ef792925cda3dc3af6487e4439f713194481540 Author: Peter Date: Fri Jun 5 18:15:13 2026 +0200 v023 Wifi Schedule diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..aa724b7 --- /dev/null +++ b/.gitignore @@ -0,0 +1,15 @@ +*.iml +.gradle +/local.properties +/.idea/caches +/.idea/libraries +/.idea/modules.xml +/.idea/workspace.xml +/.idea/navEditor.xml +/.idea/assetWizardSettings.xml +.DS_Store +/build +/captures +.externalNativeBuild +.cxx +local.properties diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..26d3352 --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,3 @@ +# Default ignored files +/shelf/ +/workspace.xml diff --git a/.idea/AndroidProjectSystem.xml b/.idea/AndroidProjectSystem.xml new file mode 100644 index 0000000..4a53bee --- /dev/null +++ b/.idea/AndroidProjectSystem.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/.idea/codeStyles/Project.xml b/.idea/codeStyles/Project.xml new file mode 100644 index 0000000..7643783 --- /dev/null +++ b/.idea/codeStyles/Project.xml @@ -0,0 +1,123 @@ + + + + + + + + + + \ No newline at end of file diff --git a/.idea/codeStyles/codeStyleConfig.xml b/.idea/codeStyles/codeStyleConfig.xml new file mode 100644 index 0000000..79ee123 --- /dev/null +++ b/.idea/codeStyles/codeStyleConfig.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/.idea/compiler.xml b/.idea/compiler.xml new file mode 100644 index 0000000..b86273d --- /dev/null +++ b/.idea/compiler.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/.idea/deploymentTargetSelector.xml b/.idea/deploymentTargetSelector.xml new file mode 100644 index 0000000..ca16a99 --- /dev/null +++ b/.idea/deploymentTargetSelector.xml @@ -0,0 +1,11 @@ + + + + + + + + + \ No newline at end of file diff --git a/.idea/gradle.xml b/.idea/gradle.xml new file mode 100644 index 0000000..02c4aa5 --- /dev/null +++ b/.idea/gradle.xml @@ -0,0 +1,18 @@ + + + + + + + \ No newline at end of file diff --git a/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml new file mode 100644 index 0000000..7061a0d --- /dev/null +++ b/.idea/inspectionProfiles/Project_Default.xml @@ -0,0 +1,61 @@ + + + + \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml new file mode 100644 index 0000000..b2c751a --- /dev/null +++ b/.idea/misc.xml @@ -0,0 +1,9 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/runConfigurations.xml b/.idea/runConfigurations.xml new file mode 100644 index 0000000..16660f1 --- /dev/null +++ b/.idea/runConfigurations.xml @@ -0,0 +1,17 @@ + + + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 0000000..94a25f7 --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/app/.gitignore b/app/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/app/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/app/build.gradle.kts b/app/build.gradle.kts new file mode 100644 index 0000000..ae11532 --- /dev/null +++ b/app/build.gradle.kts @@ -0,0 +1,62 @@ +plugins { + alias(libs.plugins.android.application) + alias(libs.plugins.kotlin.compose) +} + +android { + namespace = "com.example.retrohandy" + compileSdk { + version = release(36) { + minorApiLevel = 1 + } + } + + defaultConfig { + applicationId = "com.example.retrohandy" + minSdk = 26 + targetSdk = 36 + versionCode = 1 + versionName = "1.0" + + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + } + + buildTypes { + release { + isMinifyEnabled = false + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro" + ) + } + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 + } + buildFeatures { + compose = true + } +} + +dependencies { + implementation(platform(libs.androidx.compose.bom)) + implementation(libs.androidx.activity.compose) + implementation(libs.androidx.compose.material3) + implementation(libs.androidx.compose.ui) + implementation(libs.androidx.compose.ui.graphics) + implementation(libs.androidx.compose.ui.tooling.preview) + implementation(libs.androidx.core.ktx) + implementation(libs.androidx.lifecycle.runtime.ktx) + testImplementation(libs.junit) + androidTestImplementation(platform(libs.androidx.compose.bom)) + androidTestImplementation(libs.androidx.compose.ui.test.junit4) + androidTestImplementation(libs.androidx.espresso.core) + androidTestImplementation(libs.androidx.junit) + debugImplementation(libs.androidx.compose.ui.test.manifest) + debugImplementation(libs.androidx.compose.ui.tooling) + implementation("com.journeyapps:zxing-android-embedded:4.3.0") + implementation("com.google.zxing:core:3.5.2") + implementation("androidx.appcompat:appcompat:1.6.1") + implementation("androidx.localbroadcastmanager:localbroadcastmanager:1.1.0") +} \ No newline at end of file diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro new file mode 100644 index 0000000..481bb43 --- /dev/null +++ b/app/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/app/src/androidTest/java/com/example/retrohandy/ExampleInstrumentedTest.kt b/app/src/androidTest/java/com/example/retrohandy/ExampleInstrumentedTest.kt new file mode 100644 index 0000000..e9c2226 --- /dev/null +++ b/app/src/androidTest/java/com/example/retrohandy/ExampleInstrumentedTest.kt @@ -0,0 +1,24 @@ +package com.example.retrohandy + +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.ext.junit.runners.AndroidJUnit4 + +import org.junit.Test +import org.junit.runner.RunWith + +import org.junit.Assert.* + +/** + * Instrumented test, which will execute on an Android device. + * + * See [testing documentation](http://d.android.com/tools/testing). + */ +@RunWith(AndroidJUnit4::class) +class ExampleInstrumentedTest { + @Test + fun useAppContext() { + // Context of the app under test. + val appContext = InstrumentationRegistry.getInstrumentation().targetContext + assertEquals("com.example.retrohandy", appContext.packageName) + } +} \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..dc52d61 --- /dev/null +++ b/app/src/main/AndroidManifest.xml @@ -0,0 +1,75 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/java/com/example/retrohandy/AdminReceiver.kt b/app/src/main/java/com/example/retrohandy/AdminReceiver.kt new file mode 100644 index 0000000..ffbe14e --- /dev/null +++ b/app/src/main/java/com/example/retrohandy/AdminReceiver.kt @@ -0,0 +1,22 @@ +package com.example.retrohandy + +import android.app.admin.DeviceAdminReceiver +import android.content.Context +import android.content.Intent +import android.widget.Toast + +class AdminReceiver : DeviceAdminReceiver() { + + override fun onEnabled(context: Context, intent: Intent) { + Toast.makeText(context, "Retrohandy Admin aktiv", Toast.LENGTH_SHORT).show() + } + + override fun onDisabled(context: Context, intent: Intent) { + Toast.makeText(context, "Admin deaktiviert", Toast.LENGTH_SHORT).show() + } + + companion object { + fun getComponentName(context: Context) = + android.content.ComponentName(context, AdminReceiver::class.java) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/example/retrohandy/AlarmReceiver.kt b/app/src/main/java/com/example/retrohandy/AlarmReceiver.kt new file mode 100644 index 0000000..294bc7f --- /dev/null +++ b/app/src/main/java/com/example/retrohandy/AlarmReceiver.kt @@ -0,0 +1,58 @@ +// AlarmReceiver.kt v0.01 — Weckt App für WiFi-Sequenz +package com.example.retrohandy + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.net.wifi.WifiManager +import android.util.Log + +class AlarmReceiver : BroadcastReceiver() { + + override fun onReceive(context: Context, intent: Intent) { + val action = intent.getStringExtra("action") ?: return + Log.d("AlarmReceiver", "Alarm empfangen: $action") + + when (action) { + "WIFI_ON" -> { + // WiFi einschalten → nach 10 Min pollen + Log.d("AlarmReceiver", "WiFi einschalten...") + val wifiManager = context.applicationContext.getSystemService(Context.WIFI_SERVICE) as WifiManager + @Suppress("DEPRECATION") + wifiManager.isWifiEnabled = true + + // 10 Minuten warten dann pollen + val prefs = context.getSharedPreferences("retrohandy", Context.MODE_PRIVATE) + val code = prefs.getString("device_code", null) ?: return + ScheduleManager.schedulePoll(context, code, delayMs = 10 * 60 * 1000L) + } + + "WIFI_OFF" -> { + // Erst pollen dann WiFi ausschalten + Log.d("AlarmReceiver", "WiFi ausschalten nach Poll...") + val prefs = context.getSharedPreferences("retrohandy", Context.MODE_PRIVATE) + val code = prefs.getString("device_code", null) ?: return + SyncService.start(context, code) + + // Nach 30 Sekunden WiFi ausschalten (Zeit für Poll) + ScheduleManager.scheduleWifiOff(context, delayMs = 30_000L) + } + + "POLL" -> { + // Einmalig pollen + Log.d("AlarmReceiver", "Einmaliger Poll...") + val prefs = context.getSharedPreferences("retrohandy", Context.MODE_PRIVATE) + val code = prefs.getString("device_code", null) ?: return + SyncService.start(context, code) + } + + "WIFI_OFF_NOW" -> { + // WiFi komplett ausschalten + Log.d("AlarmReceiver", "WiFi jetzt ausschalten") + val wifiManager = context.applicationContext.getSystemService(Context.WIFI_SERVICE) as WifiManager + @Suppress("DEPRECATION") + wifiManager.isWifiEnabled = false + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/example/retrohandy/AppInstaller.kt b/app/src/main/java/com/example/retrohandy/AppInstaller.kt new file mode 100644 index 0000000..62fa09d --- /dev/null +++ b/app/src/main/java/com/example/retrohandy/AppInstaller.kt @@ -0,0 +1,60 @@ +package com.example.retrohandy + +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.os.Environment +import androidx.core.content.FileProvider +import java.io.File +import java.io.FileOutputStream +import java.net.HttpURLConnection +import java.net.URL + +object AppInstaller { + + private val apiBase = "https://retrohandy.moonplantingscience.org" + + fun install(context: Context, packageName: String, deviceCode: String) { + Thread { + try { + // APK von Hetzner laden + val url = URL("$apiBase/api/apk/download?package=$packageName&device=$deviceCode") + val conn = url.openConnection() as HttpURLConnection + conn.requestMethod = "GET" + conn.connectTimeout = 30000 + conn.readTimeout = 60000 + + if (conn.responseCode == 200) { + // APK speichern + val apkFile = File( + context.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS), + "$packageName.apk" + ) + + val input = conn.inputStream + val output = FileOutputStream(apkFile) + input.copyTo(output) + output.close() + input.close() + + // APK installieren + val apkUri: Uri = FileProvider.getUriForFile( + context, + "${context.packageName}.provider", + apkFile + ) + + val installIntent = Intent(Intent.ACTION_VIEW).apply { + setDataAndType(apkUri, "application/vnd.android.package-archive") + flags = Intent.FLAG_ACTIVITY_NEW_TASK or + Intent.FLAG_GRANT_READ_URI_PERMISSION + } + + context.startActivity(installIntent) + } + } catch (e: Exception) { + e.printStackTrace() + } + }.start() + } +} \ No newline at end of file diff --git a/app/src/main/java/com/example/retrohandy/AppManager.kt b/app/src/main/java/com/example/retrohandy/AppManager.kt new file mode 100644 index 0000000..e1f4624 --- /dev/null +++ b/app/src/main/java/com/example/retrohandy/AppManager.kt @@ -0,0 +1,59 @@ +package com.example.retrohandy + +import android.app.admin.DevicePolicyManager +import android.content.Context +import android.util.Log +import org.json.JSONArray +import org.json.JSONObject +import java.net.HttpURLConnection +import java.net.URL + +object AppManager { + + private const val API_BASE = "https://retrohandy.moonplantingscience.org" + private const val TAG = "AppManager" + + fun syncApps(context: Context, deviceCode: String) { + try { + val url = URL("$API_BASE/api/child/apps?device=$deviceCode") + val conn = url.openConnection() as HttpURLConnection + conn.requestMethod = "GET" + conn.connectTimeout = 10000 + conn.readTimeout = 10000 + + if (conn.responseCode == 200) { + val response = conn.inputStream.bufferedReader().readText() + val json = JSONObject(response) + val apps = json.getJSONArray("apps") + applyAppRestrictions(context, apps) + } else { + Log.e(TAG, "API Fehler: ${conn.responseCode}") + } + } catch (e: Exception) { + Log.e(TAG, "syncApps Fehler: ${e.message}") + } + } + + private fun applyAppRestrictions(context: Context, apps: JSONArray) { + val dpm = context.getSystemService(Context.DEVICE_POLICY_SERVICE) as DevicePolicyManager + val admin = AdminReceiver.getComponentName(context) + + if (!dpm.isDeviceOwnerApp(context.packageName)) { + Log.w(TAG, "Kein Device Owner — App-Sperren nicht möglich") + return + } + + for (i in 0 until apps.length()) { + val app = apps.getJSONObject(i) + val packageName = app.getString("package_name") + val enabled = app.getInt("enabled") == 1 + + try { + dpm.setPackagesSuspended(admin, arrayOf(packageName), !enabled) + Log.d(TAG, "${if (enabled) "Freigegeben" else "Gesperrt"}: $packageName") + } catch (e: Exception) { + Log.e(TAG, "Fehler bei $packageName: ${e.message}") + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/example/retrohandy/Contact.Manager.kt b/app/src/main/java/com/example/retrohandy/Contact.Manager.kt new file mode 100644 index 0000000..6f5dcb1 --- /dev/null +++ b/app/src/main/java/com/example/retrohandy/Contact.Manager.kt @@ -0,0 +1,77 @@ +package com.example.retrohandy + +import android.content.ContentProviderOperation +import android.content.Context +import android.provider.ContactsContract +import android.util.Log +import org.json.JSONArray +import org.json.JSONObject +import java.net.HttpURLConnection +import java.net.URL + +object ContactManager { + + private const val API_BASE = "https://retrohandy.moonplantingscience.org" + private const val TAG = "ContactManager" + + fun syncContacts(context: Context, deviceCode: String) { + try { + val url = URL("$API_BASE/api/child/contacts?device=$deviceCode") + val conn = url.openConnection() as HttpURLConnection + conn.requestMethod = "GET" + conn.connectTimeout = 10000 + conn.readTimeout = 10000 + + if (conn.responseCode == 200) { + val response = conn.inputStream.bufferedReader().readText() + val json = JSONObject(response) + val contacts = json.getJSONArray("contacts") + replaceContacts(context, contacts) + } + } catch (e: Exception) { + Log.e(TAG, "syncContacts Fehler: ${e.message}") + } + } + + private fun replaceContacts(context: Context, contacts: JSONArray) { + val resolver = context.contentResolver + + resolver.delete( + ContactsContract.RawContacts.CONTENT_URI, + "${ContactsContract.RawContacts.ACCOUNT_TYPE} = ?", + arrayOf("com.example.retrohandy") + ) + + for (i in 0 until contacts.length()) { + val c = contacts.getJSONObject(i) + val name = c.getString("name") + val phone = c.getString("phone") + + val ops = ArrayList() + + ops.add(ContentProviderOperation.newInsert(ContactsContract.RawContacts.CONTENT_URI) + .withValue(ContactsContract.RawContacts.ACCOUNT_TYPE, "com.example.retrohandy") + .withValue(ContactsContract.RawContacts.ACCOUNT_NAME, "Retrohandy") + .build()) + + ops.add(ContentProviderOperation.newInsert(ContactsContract.Data.CONTENT_URI) + .withValueBackReference(ContactsContract.Data.RAW_CONTACT_ID, 0) + .withValue(ContactsContract.Data.MIMETYPE, + ContactsContract.CommonDataKinds.StructuredName.CONTENT_ITEM_TYPE) + .withValue(ContactsContract.CommonDataKinds.StructuredName.DISPLAY_NAME, name) + .build()) + + ops.add(ContentProviderOperation.newInsert(ContactsContract.Data.CONTENT_URI) + .withValueBackReference(ContactsContract.Data.RAW_CONTACT_ID, 0) + .withValue(ContactsContract.Data.MIMETYPE, + ContactsContract.CommonDataKinds.Phone.CONTENT_ITEM_TYPE) + .withValue(ContactsContract.CommonDataKinds.Phone.NUMBER, phone) + .withValue(ContactsContract.CommonDataKinds.Phone.TYPE, + ContactsContract.CommonDataKinds.Phone.TYPE_MOBILE) + .build()) + + resolver.applyBatch(ContactsContract.AUTHORITY, ops) + Log.d(TAG, "Kontakt gesetzt: $name") + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/example/retrohandy/LauncherAcitivity.kt b/app/src/main/java/com/example/retrohandy/LauncherAcitivity.kt new file mode 100644 index 0000000..549fb1b --- /dev/null +++ b/app/src/main/java/com/example/retrohandy/LauncherAcitivity.kt @@ -0,0 +1,229 @@ +// LauncherActivity.kt v0.20 — startLockTask deaktiviert zum Testen +package com.example.retrohandy + +import android.app.admin.DevicePolicyManager +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.os.Bundle +import android.os.Handler +import android.os.Looper +import android.view.View +import android.widget.GridLayout +import android.widget.LinearLayout +import android.widget.TextView +import androidx.appcompat.app.AppCompatActivity +import androidx.localbroadcastmanager.content.LocalBroadcastManager +import org.json.JSONObject +import java.net.HttpURLConnection +import java.net.URL + +class LauncherActivity : AppCompatActivity() { + + private val apiBase = "https://retrohandy.moonplantingscience.org" + private val handler = Handler(Looper.getMainLooper()) + private lateinit var grid: GridLayout + private lateinit var dpm: DevicePolicyManager + private val refreshInterval = 30_000L + + private val appsReceiver = object : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + val json = intent.getStringExtra(SyncService.EXTRA_APPS_JSON) ?: return + renderApps(json) + } + } + + private val refreshRunnable = object : Runnable { + override fun run() { + loadApps() + handler.postDelayed(this, refreshInterval) + } + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + window.decorView.systemUiVisibility = ( + View.SYSTEM_UI_FLAG_FULLSCREEN or + View.SYSTEM_UI_FLAG_HIDE_NAVIGATION or + View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY or + View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN or + View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION + ) + + dpm = getSystemService(DEVICE_POLICY_SERVICE) as DevicePolicyManager + val admin = AdminReceiver.getComponentName(this) + if (dpm.isDeviceOwnerApp(packageName)) { + dpm.setLockTaskPackages(admin, arrayOf(packageName)) + dpm.setKeyguardDisabled(admin, true) + dpm.setStatusBarDisabled(admin, true) + } + + val root = LinearLayout(this).apply { + orientation = LinearLayout.VERTICAL + setBackgroundColor(0xFF1a1a2e.toInt()) + layoutParams = LinearLayout.LayoutParams(-1, -1) + } + + val title = TextView(this).apply { + text = "🌿 retrohandy" + textSize = 24f + setTextColor(0xFFFFFFFF.toInt()) + setPadding(40, 80, 40, 20) + } + + grid = GridLayout(this).apply { + columnCount = 3 + setPadding(20, 20, 20, 20) + } + + root.addView(title) + root.addView(grid) + setContentView(root) + + LocalBroadcastManager.getInstance(this).registerReceiver( + appsReceiver, + IntentFilter(SyncService.ACTION_APPS_UPDATED) + ) + + val prefs = getSharedPreferences("retrohandy", MODE_PRIVATE) + val code = prefs.getString("device_code", null) + if (code != null) { + SyncService.start(this, code) + val cached = prefs.getString("cached_apps", null) + if (cached != null) renderApps(cached) + } + + handler.post(refreshRunnable) + } + + override fun onResume() { + super.onResume() + // startLockTask temporär deaktiviert zum Testen + } + + override fun onDestroy() { + handler.removeCallbacks(refreshRunnable) + LocalBroadcastManager.getInstance(this).unregisterReceiver(appsReceiver) + super.onDestroy() + } + + private fun updateLockTask(packages: List) { + val admin = AdminReceiver.getComponentName(this) + if (dpm.isDeviceOwnerApp(packageName)) { + val list = (listOf(packageName) + packages).toTypedArray() + dpm.setLockTaskPackages(admin, list) + } + } + + private fun loadApps() { + val prefs = getSharedPreferences("retrohandy", MODE_PRIVATE) + val code = prefs.getString("device_code", null) ?: return + + Thread { + try { + val url = URL("$apiBase/api/child/apps?device=$code") + val conn = url.openConnection() as HttpURLConnection + conn.requestMethod = "GET" + conn.connectTimeout = 10000 + + if (conn.responseCode == 200) { + val response = conn.inputStream.bufferedReader().readText() + renderApps(response) + } + } catch (e: Exception) { + e.printStackTrace() + } + }.start() + } + + private fun renderApps(responseJson: String) { + try { + getSharedPreferences("retrohandy", MODE_PRIVATE) + .edit().putString("cached_apps", responseJson).apply() + + val json = JSONObject(responseJson) + val apps = json.getJSONArray("apps") + val enabledPackages = mutableListOf() + + for (i in 0 until apps.length()) { + val app = apps.getJSONObject(i) + if (app.getInt("enabled") == 1) { + enabledPackages.add(app.getString("package_name")) + } + } + + runOnUiThread { updateLockTask(enabledPackages) } + + runOnUiThread { + grid.removeAllViews() + for (i in 0 until apps.length()) { + val app = apps.getJSONObject(i) + val enabled = app.getInt("enabled") == 1 + val pkgName = app.getString("package_name") + val name = app.getString("name") + val emoji = app.optString("emoji", "📱") + + if (!enabled) continue + + val cell = LinearLayout(this).apply { + orientation = LinearLayout.VERTICAL + gravity = android.view.Gravity.CENTER + setPadding(20, 20, 20, 20) + val params = GridLayout.LayoutParams().apply { + width = 0 + columnSpec = GridLayout.spec(GridLayout.UNDEFINED, 1f) + } + layoutParams = params + } + + val icon = TextView(this).apply { + text = emoji + textSize = 40f + gravity = android.view.Gravity.CENTER + } + + val label = TextView(this).apply { + text = name + textSize = 12f + setTextColor(0xFFFFFFFF.toInt()) + gravity = android.view.Gravity.CENTER + } + + cell.addView(icon) + cell.addView(label) + cell.setOnClickListener { + val pm = packageManager + val intent = pm.getLaunchIntentForPackage(pkgName) + if (intent != null) { + startActivity(intent) + } else { + val prefs = getSharedPreferences("retrohandy", MODE_PRIVATE) + val code = prefs.getString("device_code", "") ?: "" + AppInstaller.install(this, pkgName, code) + } + } + grid.addView(cell) + } + } + } catch (e: Exception) { + e.printStackTrace() + } + } + + override fun onBackPressed() { } + + override fun onWindowFocusChanged(hasFocus: Boolean) { + super.onWindowFocusChanged(hasFocus) + if (hasFocus) { + window.decorView.systemUiVisibility = ( + View.SYSTEM_UI_FLAG_FULLSCREEN or + View.SYSTEM_UI_FLAG_HIDE_NAVIGATION or + View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY or + View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN or + View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION + ) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/example/retrohandy/MainActivity.kt b/app/src/main/java/com/example/retrohandy/MainActivity.kt new file mode 100644 index 0000000..73b39ac --- /dev/null +++ b/app/src/main/java/com/example/retrohandy/MainActivity.kt @@ -0,0 +1,122 @@ +// MainActivity.kt v0.18 — Launcher-Neustart nach QR-Scan +package com.example.retrohandy + +import android.Manifest +import android.content.Intent +import android.content.pm.PackageManager +import android.os.Bundle +import android.widget.Button +import android.widget.TextView +import androidx.appcompat.app.AppCompatActivity +import androidx.core.app.ActivityCompat +import androidx.core.content.ContextCompat +import com.google.zxing.integration.android.IntentIntegrator +import com.google.zxing.integration.android.IntentResult + +class MainActivity : AppCompatActivity() { + + private lateinit var statusText: TextView + private lateinit var scanButton: Button + private val CAMERA_PERMISSION = 100 + private val CONTACTS_PERMISSION = 101 + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentView(R.layout.activity_main) + + statusText = findViewById(R.id.statusText) + scanButton = findViewById(R.id.scanButton) + + checkContactsPermission() + + val prefs = getSharedPreferences("retrohandy", MODE_PRIVATE) + val savedCode = prefs.getString("device_code", null) + + if (savedCode != null) { + statusText.text = "Gerät eingerichtet ✓\nCode: $savedCode" + scanButton.text = "Neu verbinden" + SyncService.start(this, savedCode) + } else { + statusText.text = "Bitte QR-Code scannen" + } + + scanButton.setOnClickListener { + checkCameraPermissionAndScan() + } + } + + private fun checkContactsPermission() { + if (ContextCompat.checkSelfPermission(this, Manifest.permission.READ_CONTACTS) + != PackageManager.PERMISSION_GRANTED) { + ActivityCompat.requestPermissions( + this, + arrayOf( + Manifest.permission.READ_CONTACTS, + Manifest.permission.WRITE_CONTACTS + ), + CONTACTS_PERMISSION + ) + } + } + + private fun checkCameraPermissionAndScan() { + if (ContextCompat.checkSelfPermission(this, Manifest.permission.CAMERA) + != PackageManager.PERMISSION_GRANTED) { + ActivityCompat.requestPermissions( + this, arrayOf(Manifest.permission.CAMERA), CAMERA_PERMISSION + ) + } else { + startQrScan() + } + } + + private fun startQrScan() { + val integrator = IntentIntegrator(this) + integrator.setDesiredBarcodeFormats(IntentIntegrator.QR_CODE) + integrator.setPrompt("Eltern-QR-Code scannen") + integrator.setCameraId(0) + integrator.setBeepEnabled(true) + integrator.setBarcodeImageEnabled(false) + integrator.initiateScan() + } + + override fun onActivityResult(requestCode: Int, resultCode: Int, data: android.content.Intent?) { + val result: IntentResult = IntentIntegrator.parseActivityResult(requestCode, resultCode, data) + if (result.contents != null) { + val raw = result.contents + val deviceCode = if (raw.contains("device=")) { + raw.substringAfter("device=") + } else { + raw + } + val prefs = getSharedPreferences("retrohandy", MODE_PRIVATE) + prefs.edit().putString("device_code", deviceCode).apply() + statusText.text = "Verbunden ✓\nCode: $deviceCode" + SyncService.start(this, deviceCode) + + // NEU: Launcher neu starten damit er den device_code bekommt + val launcherIntent = Intent(this, LauncherActivity::class.java) + launcherIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP) + startActivity(launcherIntent) + } else { + super.onActivityResult(requestCode, resultCode, data) + } + } + + override fun onRequestPermissionsResult( + requestCode: Int, permissions: Array, grantResults: IntArray + ) { + super.onRequestPermissionsResult(requestCode, permissions, grantResults) + when (requestCode) { + CAMERA_PERMISSION -> { + if (grantResults.isNotEmpty() && + grantResults[0] == PackageManager.PERMISSION_GRANTED) { + startQrScan() + } + } + CONTACTS_PERMISSION -> { + // Permission erteilt oder verweigert — SyncService läuft trotzdem + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/example/retrohandy/ScheduleManager.kt b/app/src/main/java/com/example/retrohandy/ScheduleManager.kt new file mode 100644 index 0000000..553311e --- /dev/null +++ b/app/src/main/java/com/example/retrohandy/ScheduleManager.kt @@ -0,0 +1,141 @@ +// ScheduleManager.kt v0.01 — Verwaltet AlarmManager für WiFi-Zeitplan +package com.example.retrohandy + +import android.app.AlarmManager +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import android.util.Log +import org.json.JSONArray +import java.util.Calendar + +object ScheduleManager { + + private const val TAG = "ScheduleManager" + + // ── Zeitplan lokal speichern ────────────────────────────── + fun saveSchedule(context: Context, scheduleJson: String) { + context.getSharedPreferences("retrohandy", Context.MODE_PRIVATE) + .edit().putString("wifi_schedule", scheduleJson).apply() + Log.d(TAG, "Zeitplan gespeichert: $scheduleJson") + } + + fun loadSchedule(context: Context): JSONArray { + val json = context.getSharedPreferences("retrohandy", Context.MODE_PRIVATE) + .getString("wifi_schedule", "[]") ?: "[]" + return try { JSONArray(json) } catch (e: Exception) { JSONArray() } + } + + // ── Alle Alarme neu setzen basierend auf Zeitplan ───────── + fun applySchedule(context: Context) { + val schedule = loadSchedule(context) + if (schedule.length() == 0) { + Log.d(TAG, "Kein Zeitplan — normales Polling") + return + } + + val alarmManager = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager + cancelAllAlarms(context, alarmManager) + + val today = Calendar.getInstance().get(Calendar.DAY_OF_WEEK) - 1 // 0=So, 1=Mo... + + for (i in 0 until schedule.length()) { + val entry = schedule.getJSONObject(i) + val weekday = entry.getInt("weekday") + val fromTime = entry.getString("from_time") // "15:00" + val toTime = entry.getString("to_time") // "20:00" + + // Alarm für WiFi AN + val onTime = nextOccurrence(weekday, fromTime) + setAlarm(context, alarmManager, onTime, "WIFI_ON", requestCode = i * 2) + + // Alarm für WiFi AUS + val offTime = nextOccurrence(weekday, toTime) + setAlarm(context, alarmManager, offTime, "WIFI_OFF", requestCode = i * 2 + 1) + + Log.d(TAG, "Alarm gesetzt: Tag=$weekday AN=$fromTime AUS=$toTime") + } + } + + // ── Einmaligen Poll nach Delay planen ───────────────────── + fun schedulePoll(context: Context, deviceCode: String, delayMs: Long) { + val alarmManager = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager + val intent = Intent(context, AlarmReceiver::class.java).apply { + putExtra("action", "POLL") + } + val pending = PendingIntent.getBroadcast( + context, 9000, intent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) + alarmManager.setExactAndAllowWhileIdle( + AlarmManager.RTC_WAKEUP, + System.currentTimeMillis() + delayMs, + pending + ) + Log.d(TAG, "Poll geplant in ${delayMs / 1000}s") + } + + // ── WiFi ausschalten nach Delay ─────────────────────────── + fun scheduleWifiOff(context: Context, delayMs: Long) { + val alarmManager = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager + val intent = Intent(context, AlarmReceiver::class.java).apply { + putExtra("action", "WIFI_OFF_NOW") + } + val pending = PendingIntent.getBroadcast( + context, 9001, intent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) + alarmManager.setExactAndAllowWhileIdle( + AlarmManager.RTC_WAKEUP, + System.currentTimeMillis() + delayMs, + pending + ) + Log.d(TAG, "WiFi-Aus geplant in ${delayMs / 1000}s") + } + + // ── Hilfsfunktionen ─────────────────────────────────────── + private fun nextOccurrence(weekday: Int, time: String): Long { + val parts = time.split(":") + val hour = parts[0].toInt() + val minute = parts[1].toInt() + + val cal = Calendar.getInstance() + // Android: 1=So, 2=Mo... wir nutzen 0=So, 1=Mo + val androidDay = if (weekday == 0) Calendar.SUNDAY else weekday + 1 + + cal.set(Calendar.DAY_OF_WEEK, androidDay) + cal.set(Calendar.HOUR_OF_DAY, hour) + cal.set(Calendar.MINUTE, minute) + cal.set(Calendar.SECOND, 0) + cal.set(Calendar.MILLISECOND, 0) + + // Falls Zeit in der Vergangenheit → nächste Woche + if (cal.timeInMillis < System.currentTimeMillis()) { + cal.add(Calendar.WEEK_OF_YEAR, 1) + } + + return cal.timeInMillis + } + + private fun setAlarm(context: Context, alarmManager: AlarmManager, timeMs: Long, action: String, requestCode: Int) { + val intent = Intent(context, AlarmReceiver::class.java).apply { + putExtra("action", action) + } + val pending = PendingIntent.getBroadcast( + context, requestCode, intent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) + alarmManager.setExactAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, timeMs, pending) + } + + private fun cancelAllAlarms(context: Context, alarmManager: AlarmManager) { + for (i in 0..20) { + val intent = Intent(context, AlarmReceiver::class.java) + val pending = PendingIntent.getBroadcast( + context, i, intent, + PendingIntent.FLAG_NO_CREATE or PendingIntent.FLAG_IMMUTABLE + ) + pending?.let { alarmManager.cancel(it) } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/example/retrohandy/SyncService.kt b/app/src/main/java/com/example/retrohandy/SyncService.kt new file mode 100644 index 0000000..86503a7 --- /dev/null +++ b/app/src/main/java/com/example/retrohandy/SyncService.kt @@ -0,0 +1,121 @@ +// SyncService.kt v0.23 — Zeitplan speichern + AlarmManager +package com.example.retrohandy + +import android.app.Service +import android.content.Context +import android.content.Intent +import android.net.wifi.WifiManager +import android.os.Handler +import android.os.IBinder +import android.os.Looper +import android.util.Log +import androidx.localbroadcastmanager.content.LocalBroadcastManager +import org.json.JSONArray +import org.json.JSONObject +import java.net.HttpURLConnection +import java.net.URL + +class SyncService : Service() { + + private val handler = Handler(Looper.getMainLooper()) + private val intervalMs = 30_000L + private val apiBase = "https://retrohandy.moonplantingscience.org" + private lateinit var deviceCode: String + + companion object { + const val ACTION_APPS_UPDATED = "com.example.retrohandy.APPS_UPDATED" + const val EXTRA_APPS_JSON = "apps_json" + + fun start(context: Context, deviceCode: String) { + val intent = Intent(context, SyncService::class.java) + intent.putExtra("device_code", deviceCode) + context.startService(intent) + } + } + + private val syncRunnable = object : Runnable { + override fun run() { + fetchAndBroadcast() + // Weiterpollen nur wenn kein Zeitplan aktiv + val schedule = ScheduleManager.loadSchedule(this@SyncService) + if (schedule.length() == 0) { + handler.postDelayed(this, intervalMs) + } + } + } + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + val code = intent?.getStringExtra("device_code") ?: run { + stopSelf() + return START_NOT_STICKY + } + deviceCode = code + handler.removeCallbacks(syncRunnable) + handler.post(syncRunnable) + return START_NOT_STICKY // Einmalig — AlarmManager übernimmt danach + } + + override fun onDestroy() { + handler.removeCallbacks(syncRunnable) + super.onDestroy() + } + + override fun onBind(intent: Intent?): IBinder? = null + + private fun fetchAndBroadcast() { + Thread { + try { + val url = URL("$apiBase/api/child/apps?device=$deviceCode") + val conn = url.openConnection() as HttpURLConnection + conn.requestMethod = "GET" + conn.connectTimeout = 10000 + + if (conn.responseCode == 200) { + val response = conn.inputStream.bufferedReader().readText() + Log.d("SyncService", "Fetched: $response") + + // ── Broadcast ZUERST — Apps immer anzeigen ──────────── + val broadcastIntent = Intent(ACTION_APPS_UPDATED).apply { + putExtra(EXTRA_APPS_JSON, response) + } + LocalBroadcastManager.getInstance(this@SyncService) + .sendBroadcast(broadcastIntent) + + // ── Zeitplan speichern + AlarmManager neu setzen ────── + try { + val json = JSONObject(response) + val scheduleArray = json.optJSONArray("wifi_schedule") ?: JSONArray() + ScheduleManager.saveSchedule(this@SyncService, scheduleArray.toString()) + ScheduleManager.applySchedule(this@SyncService) + Log.d("SyncService", "Zeitplan aktualisiert: ${scheduleArray.length()} Eintraege") + } catch (e: Exception) { + Log.e("SyncService", "Zeitplan-Fehler: ${e.message}") + } + + // ── WiFi nur steuern wenn kein Zeitplan aktiv ───────── + try { + val json = JSONObject(response) + val schedule = ScheduleManager.loadSchedule(this@SyncService) + if (schedule.length() == 0) { + // Kein Zeitplan → manueller Toggle + val settings = json.optJSONObject("settings") + if (settings != null) { + val wifiEnabled = settings.optInt("wifi_enabled", 1) == 1 + val wifiManager = applicationContext.getSystemService(Context.WIFI_SERVICE) as WifiManager + if (wifiManager.isWifiEnabled != wifiEnabled) { + @Suppress("DEPRECATION") + wifiManager.isWifiEnabled = wifiEnabled + Log.d("SyncService", "WiFi manuell gesetzt: $wifiEnabled") + } + } + } + } catch (e: Exception) { + Log.e("SyncService", "WiFi-Fehler: ${e.message}") + } + } + } catch (e: Exception) { + Log.e("SyncService", "Fetch error: ${e.message}") + } + }.start() + } +} \ No newline at end of file diff --git a/app/src/main/java/com/example/retrohandy/ui/theme/Color.kt b/app/src/main/java/com/example/retrohandy/ui/theme/Color.kt new file mode 100644 index 0000000..31c7fda --- /dev/null +++ b/app/src/main/java/com/example/retrohandy/ui/theme/Color.kt @@ -0,0 +1,11 @@ +package com.example.retrohandy.ui.theme + +import androidx.compose.ui.graphics.Color + +val Purple80 = Color(0xFFD0BCFF) +val PurpleGrey80 = Color(0xFFCCC2DC) +val Pink80 = Color(0xFFEFB8C8) + +val Purple40 = Color(0xFF6650a4) +val PurpleGrey40 = Color(0xFF625b71) +val Pink40 = Color(0xFF7D5260) \ No newline at end of file diff --git a/app/src/main/java/com/example/retrohandy/ui/theme/Theme.kt b/app/src/main/java/com/example/retrohandy/ui/theme/Theme.kt new file mode 100644 index 0000000..a7b3fbe --- /dev/null +++ b/app/src/main/java/com/example/retrohandy/ui/theme/Theme.kt @@ -0,0 +1,58 @@ +package com.example.retrohandy.ui.theme + +import android.app.Activity +import android.os.Build +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.dynamicDarkColorScheme +import androidx.compose.material3.dynamicLightColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.platform.LocalContext + +private val DarkColorScheme = darkColorScheme( + primary = Purple80, + secondary = PurpleGrey80, + tertiary = Pink80 +) + +private val LightColorScheme = lightColorScheme( + primary = Purple40, + secondary = PurpleGrey40, + tertiary = Pink40 + + /* Other default colors to override + background = Color(0xFFFFFBFE), + surface = Color(0xFFFFFBFE), + onPrimary = Color.White, + onSecondary = Color.White, + onTertiary = Color.White, + onBackground = Color(0xFF1C1B1F), + onSurface = Color(0xFF1C1B1F), + */ +) + +@Composable +fun RetrohandyTheme( + darkTheme: Boolean = isSystemInDarkTheme(), + // Dynamic color is available on Android 12+ + dynamicColor: Boolean = true, + content: @Composable () -> Unit +) { + val colorScheme = when { + dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> { + val context = LocalContext.current + if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) + } + + darkTheme -> DarkColorScheme + else -> LightColorScheme + } + + MaterialTheme( + colorScheme = colorScheme, + typography = Typography, + content = content + ) +} \ No newline at end of file diff --git a/app/src/main/java/com/example/retrohandy/ui/theme/Type.kt b/app/src/main/java/com/example/retrohandy/ui/theme/Type.kt new file mode 100644 index 0000000..c70d81c --- /dev/null +++ b/app/src/main/java/com/example/retrohandy/ui/theme/Type.kt @@ -0,0 +1,34 @@ +package com.example.retrohandy.ui.theme + +import androidx.compose.material3.Typography +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.sp + +// Set of Material typography styles to start with +val Typography = Typography( + bodyLarge = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Normal, + fontSize = 16.sp, + lineHeight = 24.sp, + letterSpacing = 0.5.sp + ) + /* Other default text styles to override + titleLarge = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Normal, + fontSize = 22.sp, + lineHeight = 28.sp, + letterSpacing = 0.sp + ), + labelSmall = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Medium, + fontSize = 11.sp, + lineHeight = 16.sp, + letterSpacing = 0.5.sp + ) + */ +) \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_launcher_background.xml b/app/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 0000000..07d5da9 --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,170 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_launcher_foreground.xml b/app/src/main/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 0000000..2b068d1 --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,30 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml new file mode 100644 index 0000000..684491b --- /dev/null +++ b/app/src/main/res/layout/activity_main.xml @@ -0,0 +1,30 @@ + + + + + +