Set up project skeleton (milestone 1)

- Architecture and feature overview in docs/ARCHITECTURE.md
- Gradle 9.8 / AGP 9.4 / Kotlin 2.4 with version catalog and convention plugins
- Modules :app, :core:*, :device-profiles, :feature:* (placeholder screens)
- Flavors core (no INTERNET, enforced by a build check) and full
- Material You theme, navigation, expert mode and dry-run settings (DataStore)
- Pixel 8-10 device detection, unit tests, GPLv3 license, English README with Obtainium badge

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
2026-10-15 22:02:14 +02:00
co-authored by Claude Opus 5.5
commit 0a7caf6c5f
117 changed files with 3175 additions and 0 deletions
+81
View File
@@ -0,0 +1,81 @@
plugins {
alias(libs.plugins.pixelwall.android.application)
alias(libs.plugins.pixelwall.android.compose)
alias(libs.plugins.pixelwall.android.hilt)
}
android {
namespace = "org.pixelwall.app"
defaultConfig {
applicationId = "org.pixelwall.app"
versionCode = 1
versionName = "0.1.0"
}
flavorDimensions += "network"
productFlavors {
// Ohne INTERNET-Berechtigung. Firewall nur als Sperre pro App.
create("core") {
dimension = "network"
isDefault = true
}
// Mit INTERNET: DNS-Filter, Blocklisten, optionale Regelwerk-Updates.
create("full") {
dimension = "network"
applicationIdSuffix = ".full"
versionNameSuffix = "-full"
}
}
buildTypes {
release {
isMinifyEnabled = true
isShrinkResources = true
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
}
}
// F-Droid / reproducible builds: keine verschlüsselten Abhängigkeits-Metadaten im APK.
dependenciesInfo {
includeInApk = false
includeInBundle = false
}
androidResources {
localeFilters += listOf("en", "de")
}
}
dependencies {
implementation(project(":core:model"))
implementation(project(":core:domain"))
implementation(project(":core:data"))
implementation(project(":core:system"))
implementation(project(":core:privileged"))
implementation(project(":core:ui"))
implementation(project(":device-profiles"))
implementation(project(":feature:dashboard"))
implementation(project(":feature:permissions"))
implementation(project(":feature:network"))
implementation(project(":feature:system"))
implementation(project(":feature:profiles"))
implementation(project(":feature:trackers"))
implementation(project(":feature:monitor"))
implementation(project(":feature:changelog"))
implementation(project(":feature:onboarding"))
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.activity.compose)
implementation(libs.androidx.lifecycle.runtime.compose)
implementation(libs.androidx.lifecycle.viewmodel.compose)
implementation(libs.androidx.navigation.compose)
implementation(libs.androidx.hilt.navigation.compose)
testImplementation(project(":core:testing"))
testImplementation(libs.truth)
testImplementation(libs.turbine)
androidTestImplementation(libs.androidx.test.ext.junit)
androidTestImplementation(libs.androidx.test.runner)
}
+1
View File
@@ -0,0 +1 @@
# Projektspezifische R8-Regeln. Hilt, Compose und DataStore bringen eigene Consumer-Regeln mit.
+7
View File
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Flavor "core": Netzwerkzugriff ist ausgeschlossen, auch wenn eine Bibliothek ihn anfordert. -->
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.INTERNET" tools:node="remove" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" tools:node="remove" />
</manifest>
+5
View File
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Flavor "full": INTERNET nur für DNS-Weiterleitung der Firewall und optionale Regelwerk-Updates. -->
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
</manifest>
+24
View File
@@ -0,0 +1,24 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application
android:name=".PixelwallApplication"
android:allowBackup="false"
android:dataExtractionRules="@xml/data_extraction_rules"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher"
android:supportsRtl="true"
android:theme="@android:style/Theme.Material.NoActionBar">
<activity
android:name=".MainActivity"
android:exported="true"
android:windowSoftInputMode="adjustResize">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>
@@ -0,0 +1,22 @@
package org.pixelwall.app
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import dagger.hilt.android.AndroidEntryPoint
import org.pixelwall.app.navigation.PixelwallApp
import org.pixelwall.core.ui.theme.PixelwallTheme
@AndroidEntryPoint
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
enableEdgeToEdge()
super.onCreate(savedInstanceState)
setContent {
PixelwallTheme {
PixelwallApp()
}
}
}
}
@@ -0,0 +1,7 @@
package org.pixelwall.app
import android.app.Application
import dagger.hilt.android.HiltAndroidApp
@HiltAndroidApp
class PixelwallApplication : Application()
@@ -0,0 +1,99 @@
package org.pixelwall.app.navigation
import androidx.annotation.StringRes
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Apps
import androidx.compose.material.icons.outlined.Lan
import androidx.compose.material.icons.outlined.MoreHoriz
import androidx.compose.material.icons.outlined.Security
import androidx.compose.material.icons.outlined.Settings
import androidx.compose.material3.Icon
import androidx.compose.material3.NavigationBar
import androidx.compose.material3.NavigationBarItem
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.stringResource
import androidx.navigation.NavDestination.Companion.hierarchy
import androidx.navigation.NavGraph.Companion.findStartDestination
import androidx.navigation.NavHostController
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.navigation.compose.rememberNavController
import org.pixelwall.app.R
import org.pixelwall.app.settings.MoreScreen
import org.pixelwall.feature.changelog.changelogScreen
import org.pixelwall.feature.dashboard.DASHBOARD_ROUTE
import org.pixelwall.feature.dashboard.dashboardScreen
import org.pixelwall.feature.monitor.monitorScreen
import org.pixelwall.feature.network.NETWORK_ROUTE
import org.pixelwall.feature.network.networkScreen
import org.pixelwall.feature.onboarding.onboardingScreen
import org.pixelwall.feature.permissions.PERMISSIONS_ROUTE
import org.pixelwall.feature.permissions.permissionsScreen
import org.pixelwall.feature.profiles.profilesScreen
import org.pixelwall.feature.system.SYSTEM_ROUTE
import org.pixelwall.feature.system.systemScreen
import org.pixelwall.feature.trackers.trackersScreen
const val MORE_ROUTE = "more"
enum class TopLevelDestination(val route: String, @StringRes val label: Int, val icon: ImageVector) {
DASHBOARD(DASHBOARD_ROUTE, R.string.nav_dashboard, Icons.Outlined.Security),
PERMISSIONS(PERMISSIONS_ROUTE, R.string.nav_permissions, Icons.Outlined.Apps),
NETWORK(NETWORK_ROUTE, R.string.nav_network, Icons.Outlined.Lan),
SYSTEM(SYSTEM_ROUTE, R.string.nav_system, Icons.Outlined.Settings),
MORE(MORE_ROUTE, R.string.nav_more, Icons.Outlined.MoreHoriz),
}
@Composable
fun PixelwallApp(navController: NavHostController = rememberNavController()) {
val backStackEntry by navController.currentBackStackEntryAsState()
val currentDestination = backStackEntry?.destination
Scaffold(
bottomBar = {
NavigationBar {
TopLevelDestination.entries.forEach { dest ->
val selected = currentDestination?.hierarchy?.any { it.route == dest.route } == true
NavigationBarItem(
selected = selected,
onClick = {
navController.navigate(dest.route) {
popUpTo(navController.graph.findStartDestination().id) { saveState = true }
launchSingleTop = true
restoreState = true
}
},
icon = { Icon(dest.icon, contentDescription = null) },
label = { Text(stringResource(dest.label)) },
)
}
}
},
) { padding ->
NavHost(
navController = navController,
startDestination = DASHBOARD_ROUTE,
modifier = Modifier.padding(padding),
) {
dashboardScreen()
permissionsScreen()
networkScreen()
systemScreen()
profilesScreen()
trackersScreen()
monitorScreen()
changelogScreen()
onboardingScreen()
composable(MORE_ROUTE) {
MoreScreen(onNavigate = { route -> navController.navigate(route) })
}
}
}
}
@@ -0,0 +1,116 @@
package org.pixelwall.app.settings
import androidx.annotation.StringRes
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.selection.toggleable
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.outlined.ListAlt
import androidx.compose.material.icons.outlined.AdminPanelSettings
import androidx.compose.material.icons.outlined.BugReport
import androidx.compose.material.icons.outlined.NotificationsActive
import androidx.compose.material.icons.outlined.Tune
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.ListItem
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.semantics.heading
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import org.pixelwall.app.R
import org.pixelwall.feature.changelog.CHANGELOG_ROUTE
import org.pixelwall.feature.monitor.MONITOR_ROUTE
import org.pixelwall.feature.onboarding.ONBOARDING_ROUTE
import org.pixelwall.feature.profiles.PROFILES_ROUTE
import org.pixelwall.feature.trackers.TRACKERS_ROUTE
private data class MoreEntry(@StringRes val label: Int, val icon: ImageVector, val route: String)
private val toolEntries = listOf(
MoreEntry(R.string.more_profiles, Icons.Outlined.Tune, PROFILES_ROUTE),
MoreEntry(R.string.more_trackers, Icons.Outlined.BugReport, TRACKERS_ROUTE),
MoreEntry(R.string.more_monitor, Icons.Outlined.NotificationsActive, MONITOR_ROUTE),
MoreEntry(R.string.more_changelog, Icons.AutoMirrored.Outlined.ListAlt, CHANGELOG_ROUTE),
MoreEntry(R.string.more_onboarding, Icons.Outlined.AdminPanelSettings, ONBOARDING_ROUTE),
)
@Composable
fun MoreScreen(onNavigate: (String) -> Unit, viewModel: MoreViewModel = hiltViewModel()) {
val state by viewModel.uiState.collectAsStateWithLifecycle()
LazyColumn(modifier = Modifier.fillMaxSize()) {
item {
ListItem(
headlineContent = { Text(state.device.model) },
supportingContent = {
Text(
stringResource(R.string.device_line, state.device.device, state.device.release, state.device.securityPatch) +
if (state.isSupportedPixel) "" else "\n" + stringResource(R.string.device_unsupported),
)
},
)
HorizontalDivider()
}
item { SectionHeader(R.string.more_section_tools) }
toolEntries.forEach { entry ->
item {
ListItem(
headlineContent = { Text(stringResource(entry.label)) },
leadingContent = { Icon(entry.icon, contentDescription = null) },
modifier = Modifier.clickable(role = Role.Button) { onNavigate(entry.route) },
)
}
}
item { SectionHeader(R.string.more_section_settings) }
item {
SwitchItem(
title = R.string.setting_expert_mode,
summary = R.string.setting_expert_mode_summary,
checked = state.preferences.expertMode,
onCheckedChange = viewModel::setExpertMode,
)
}
item {
SwitchItem(
title = R.string.setting_dry_run,
summary = R.string.setting_dry_run_summary,
checked = state.preferences.dryRun,
onCheckedChange = viewModel::setDryRun,
)
}
}
}
@Composable
private fun SectionHeader(@StringRes text: Int) {
Text(
text = stringResource(text),
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier
.padding(start = 16.dp, top = 24.dp, end = 16.dp, bottom = 8.dp)
.semantics { heading() },
)
}
@Composable
private fun SwitchItem(@StringRes title: Int, @StringRes summary: Int, checked: Boolean, onCheckedChange: (Boolean) -> Unit) {
ListItem(
headlineContent = { Text(stringResource(title)) },
supportingContent = { Text(stringResource(summary)) },
// Der Switch selbst ist nicht fokussierbar; die ganze Zeile ist der Schalter (TalkBack liest Titel + Zustand).
trailingContent = { Switch(checked = checked, onCheckedChange = null) },
modifier = Modifier.toggleable(value = checked, role = Role.Switch, onValueChange = onCheckedChange),
)
}
@@ -0,0 +1,45 @@
package org.pixelwall.app.settings
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import org.pixelwall.core.domain.DeviceInfoProvider
import org.pixelwall.core.domain.UserPreferences
import org.pixelwall.core.domain.UserPreferencesRepository
import org.pixelwall.core.model.DeviceInfo
import org.pixelwall.deviceprofiles.DeviceProfiles
import org.pixelwall.deviceprofiles.PixelProfile
import javax.inject.Inject
data class MoreUiState(
val preferences: UserPreferences = UserPreferences(),
val device: DeviceInfo,
val isSupportedPixel: Boolean,
)
@HiltViewModel
class MoreViewModel @Inject constructor(
private val preferencesRepository: UserPreferencesRepository,
deviceInfoProvider: DeviceInfoProvider,
) : ViewModel() {
private val device = deviceInfoProvider.current()
private val supported = DeviceProfiles.resolve(device) == PixelProfile
val uiState: StateFlow<MoreUiState> = preferencesRepository.preferences
.map { MoreUiState(it, device, supported) }
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), MoreUiState(device = device, isSupportedPixel = supported))
fun setExpertMode(enabled: Boolean) {
viewModelScope.launch { preferencesRepository.setExpertMode(enabled) }
}
fun setDryRun(enabled: Boolean) {
viewModelScope.launch { preferencesRepository.setDryRun(enabled) }
}
}
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle">
<solid android:color="#FF1F3B63" />
</shape>
@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Schild mit Pixelraster, 108dp-Raster, Inhalt in der 66dp-Sicherheitszone. -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:fillColor="#FFFFFFFF"
android:pathData="M54,28 L76,36 L76,54 C76,67 66,77 54,81 C42,77 32,67 32,54 L32,36 Z" />
<path
android:fillColor="#FF1F3B63"
android:pathData="M44,44h8v8h-8z M56,44h8v8h-8z M44,56h8v8h-8z M56,56h8v8h-8z" />
</vector>
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>
+22
View File
@@ -0,0 +1,22 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="nav_dashboard">Übersicht</string>
<string name="nav_permissions">Berechtigungen</string>
<string name="nav_network">Netzwerk</string>
<string name="nav_system">System</string>
<string name="nav_more">Mehr</string>
<string name="more_title">Mehr</string>
<string name="more_section_tools">Werkzeuge</string>
<string name="more_section_settings">Einstellungen</string>
<string name="more_profiles">Profile &amp; Automatisierung</string>
<string name="more_trackers">Tracker-Analyse</string>
<string name="more_monitor">Überwachung</string>
<string name="more_changelog">Änderungsprotokoll &amp; Rückgängig</string>
<string name="more_onboarding">Zugriffsstufen (Shizuku, ADB)</string>
<string name="setting_expert_mode">Expertenmodus</string>
<string name="setting_expert_mode_summary">Zeigt die rohen Settings-Keys und Shell-Befehle an, die ausgeführt werden.</string>
<string name="setting_dry_run">Probelauf (Dry Run)</string>
<string name="setting_dry_run_summary">Zeigt nur an, was sich ändern würde. Es wird nichts angewendet.</string>
<string name="device_line">%1$s · Android %2$s · Patch %3$s</string>
<string name="device_unsupported">Dieses Gerät ist kein unterstütztes Pixel. Gerätespezifische Funktionen sind ausgegraut.</string>
</resources>
+23
View File
@@ -0,0 +1,23 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name" translatable="false">Pixelwall</string>
<string name="nav_dashboard">Overview</string>
<string name="nav_permissions">Permissions</string>
<string name="nav_network">Network</string>
<string name="nav_system">System</string>
<string name="nav_more">More</string>
<string name="more_title">More</string>
<string name="more_section_tools">Tools</string>
<string name="more_section_settings">Settings</string>
<string name="more_profiles">Profiles &amp; automation</string>
<string name="more_trackers">Tracker analysis</string>
<string name="more_monitor">Monitoring</string>
<string name="more_changelog">Change log &amp; undo</string>
<string name="more_onboarding">Access levels (Shizuku, ADB)</string>
<string name="setting_expert_mode">Expert mode</string>
<string name="setting_expert_mode_summary">Show the raw settings keys and shell commands that will run.</string>
<string name="setting_dry_run">Dry run</string>
<string name="setting_dry_run_summary">Only show what would change. Nothing is applied.</string>
<string name="device_line">%1$s · Android %2$s · patch %3$s</string>
<string name="device_unsupported">This device is not a supported Pixel. Device-specific features are greyed out.</string>
</resources>
@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Keine Cloud-Sicherung und keine Geräteübertragung von Protokollen und Snapshots. -->
<data-extraction-rules>
<cloud-backup>
<exclude domain="root" />
<exclude domain="file" />
<exclude domain="database" />
<exclude domain="sharedpref" />
<exclude domain="external" />
</cloud-backup>
<device-transfer>
<exclude domain="root" />
<exclude domain="file" />
<exclude domain="database" />
<exclude domain="sharedpref" />
<exclude domain="external" />
</device-transfer>
</data-extraction-rules>
@@ -0,0 +1,43 @@
package org.pixelwall.app.settings
import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import kotlinx.coroutines.test.runTest
import org.junit.Rule
import org.junit.Test
import org.pixelwall.core.domain.DeviceInfoProvider
import org.pixelwall.core.model.DeviceInfo
import org.pixelwall.core.testing.FakeUserPreferencesRepository
import org.pixelwall.core.testing.MainDispatcherRule
class MoreViewModelTest {
@get:Rule
val mainDispatcherRule = MainDispatcherRule()
private fun device(manufacturer: String, codename: String) = DeviceInfoProvider {
DeviceInfo(manufacturer, manufacturer.lowercase(), codename, codename, 36, "16", "2026-10-05")
}
@Test
fun pixelIsReportedAsSupported() {
val vm = MoreViewModel(FakeUserPreferencesRepository(), device("Google", "caiman"))
assertThat(vm.uiState.value.isSupportedPixel).isTrue()
}
@Test
fun otherDeviceIsReportedAsUnsupported() {
val vm = MoreViewModel(FakeUserPreferencesRepository(), device("OnePlus", "aston"))
assertThat(vm.uiState.value.isSupportedPixel).isFalse()
}
@Test
fun togglingDryRunUpdatesState() = runTest {
val vm = MoreViewModel(FakeUserPreferencesRepository(), device("Google", "shiba"))
vm.uiState.test {
assertThat(awaitItem().preferences.dryRun).isFalse()
vm.setDryRun(true)
assertThat(expectMostRecentItem().preferences.dryRun).isTrue()
}
}
}