Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions .editorconfig
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
root = true

[*]
charset = utf-8
end_of_line = lf
insert_final_newline = true
trim_trailing_whitespace = true

[*.{kt,kts}]
indent_size = 4
indent_style = space
ij_kotlin_allow_trailing_comma = true
ij_kotlin_allow_trailing_comma_on_call_site = true
ij_kotlin_name_count_to_use_star_import = 2147483647
ij_kotlin_name_count_to_use_star_import_for_members = 2147483647
ij_kotlin_packages_to_use_import_on_demand = unset
ktlint_class_signature_rule_force_multiline_when_parameter_count_greater_or_equal_than = 1
ij_kotlin_line_break_after_multiline_when_entry = false
ktlint_code_style = android_studio
ktlint_function_naming_ignore_when_annotated_with = Composable
ktlint_standard_filename = disabled
ktlint_standard_function-expression-body = disabled
ktlint_standard_function-signature = disabled
ktlint_standard_trailing-comma-on-call-site = disabled
ktlint_standard_blank-line-between-when-conditions = disabled
max_line_length = 100
430 changes: 430 additions & 0 deletions AGENTS.md

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions CLAUDE.md
1 change: 1 addition & 0 deletions GEMINI.md
71 changes: 71 additions & 0 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
import dev.detekt.gradle.Detekt
import dev.detekt.gradle.DetektCreateBaselineTask
import java.io.FileInputStream
import java.util.Properties
import org.jetbrains.kotlin.gradle.tasks.KotlinJvmCompile

val keystorePropertiesFile = rootProject.file("keystore.properties")
val useKeystoreProperties = keystorePropertiesFile.canRead()
Expand All @@ -10,6 +13,62 @@ if (useKeystoreProperties) {

plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.detekt)
}

detekt {
basePath.set(rootDir)
baseline = file("detekt-baseline.xml")
buildUponDefaultConfig = true
config.setFrom(rootProject.file("config/detekt/detekt.yml"))
ignoredBuildTypes = listOf("release")
parallel = true
}

// detekt's classpath convention is the compilation's dependencies and nothing else, so BuildConfig
// and androidxc/ resolve to nothing and every type-aware rule goes quiet instead of reporting. A
// Gradle convention cannot be appended to: `from` would discard it, hence `setFrom` with both.
fun addOwnClassesToDetektClasspath(
classpath: ConfigurableFileCollection,
variantName: String,
) {
classpath.setFrom(
tasks.named<KotlinJvmCompile>("compile${variantName}Kotlin").map { it.libraries },
tasks.named("compile${variantName}JavaWithJavac").map { it.outputs.files },
)
}

// Only the variants `check` gates on below. The plugin's other detekt tasks analyse a source set
// at a time without types and have no compilation to take a classpath from.
listOf("Debug", "DebugUnitTest", "DebugAndroidTest").forEach { variantName ->
tasks
.withType<Detekt>()
.matching { it.name == "detekt$variantName" }
.configureEach {
addOwnClassesToDetektClasspath(classpath, variantName)
}

tasks
.withType<DetektCreateBaselineTask>()
.matching { it.name == "detektBaseline$variantName" }
.configureEach {
addOwnClassesToDetektClasspath(classpath, variantName)
}
}

// The aggregate `detekt` task analyses every source set at once without type resolution, so it
// cannot see what the type-aware rules exist for. The debug variants cover the same sources with
// types, so `check` gates on those and the aggregate stays off.
tasks.named("check") {
dependsOn(
tasks.named("detektDebug"),
tasks.named("detektDebugUnitTest"),
tasks.named("detektDebugAndroidTest"),
)
}

tasks.named("detekt") {
enabled = false
}

java {
Expand Down Expand Up @@ -89,6 +148,14 @@ android {
androidResources {
localeFilters += listOf("en")
}

testOptions {
unitTests {
// Robolectric builds its application under test from the merged manifest and
// resources; without this it cannot start one.
isIncludeAndroidResources = true
}
}
}

dependencies {
Expand All @@ -101,6 +168,10 @@ dependencies {

implementation(libs.zxing.core)

testImplementation(libs.junit4)
testImplementation(libs.robolectric)
testImplementation(libs.androidx.test.core.ktx)

androidTestImplementation(libs.androidx.test.core.ktx)
androidTestImplementation(libs.androidx.test.ext.junit.ktx)
androidTestImplementation(libs.androidx.test.rules)
Expand Down
932 changes: 932 additions & 0 deletions app/config/ktlint/baseline.xml

Large diffs are not rendered by default.

235 changes: 235 additions & 0 deletions app/detekt-baseline-debug.xml

Large diffs are not rendered by default.

11 changes: 11 additions & 0 deletions app/detekt-baseline-debugAndroidTest.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
<?xml version="1.0" ?>
<SmellBaseline>
<ManuallySuppressedIssues/>
<CurrentIssues>
<ID>AbstractClassCanBeConcreteClass:EditMediaRegressionTest.kt:EditMediaRegressionTest.HostActivity$HostActivity</ID>
<ID>AbstractClassCanBeConcreteClass:ShareMediaRegressionTest.kt:ShareMediaRegressionTest.HostActivity$HostActivity</ID>
<ID>EmptyFunctionBlock:InAppGalleryRegressionTest.kt:InAppGalleryRegressionTest.StalledMediaScan${}</ID>
<ID>PrintStackTrace:VideoCapturerRegressionTest.kt:VideoCapturerRegressionTest$e</ID>
<ID>UseCheckOrError:VideoPlayerRegressionTest.kt:VideoPlayerRegressionTest.DeadMediaServiceVideoView$throw IllegalStateException("prepareAsync called in state 0")</ID>
</CurrentIssues>
</SmellBaseline>
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
package app.grapheneos.camera

import android.content.ComponentName
import android.content.Intent
import android.content.pm.ActivityInfo
import android.content.pm.PackageManager
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith

@RunWith(AndroidJUnit4::class)
class EntryPointContractTest {
private val context = InstrumentationRegistry.getInstrumentation().targetContext
private val packageManager: PackageManager = context.packageManager

private fun activityInfoFor(action: String): ActivityInfo {
val intent = Intent(action).setPackage(context.packageName)
val matches = packageManager.queryIntentActivities(intent, PackageManager.MATCH_ALL)

assertEquals(
"Exactly one component in this app must answer $action, but got" +
" ${matches.map { it.activityInfo.name }}",
1,
matches.size,
)
return matches.single().activityInfo
}

private fun assertHandledBy(
action: String,
expectedComponent: String,
) {
assertEquals(
"$action must be handled by $expectedComponent",
"$PACKAGE.$expectedComponent",
activityInfoFor(action).name,
)
}

private fun assertIsLockscreenEntryPoint(
action: String,
expectedAffinity: String,
) {
val info = activityInfoFor(action)

assertTrue(
"${info.name} must show over the keyguard, or $action does nothing on a locked" +
" phone",
info.flags and FLAG_SHOW_WHEN_LOCKED != 0,
)
assertTrue(
"${info.name} must be excluded from recents, or what a locked session captured is" +
" listed to whoever picks the phone up next",
info.flags and ActivityInfo.FLAG_EXCLUDE_FROM_RECENTS != 0,
)

assertTrue(
"${info.name} must keep its own taskAffinity ending in" +
" .ui.activities.$expectedAffinity, or the locked session can surface the" +
" unlocked task, but it is ${info.taskAffinity}",
info.taskAffinity.orEmpty().endsWith(".ui.activities.$expectedAffinity"),
)
}

@Test
fun stillImageCameraIsAnAliasOntoTheMainActivity() {
val info = activityInfoFor("android.media.action.STILL_IMAGE_CAMERA")

assertEquals("$PACKAGE.ui.activities.CameraLauncher", info.name)
assertEquals("$PACKAGE.ui.activities.MainActivity", info.targetActivity)
}

@Test
fun videoCameraLaunchesTheVideoOnlyActivity() {
assertHandledBy("android.media.action.VIDEO_CAMERA", "ui.activities.VideoOnlyActivity")
}

@Test
fun imageCaptureLaunchesTheCaptureActivity() {
assertHandledBy("android.media.action.IMAGE_CAPTURE", "ui.activities.CaptureActivity")
}

@Test
fun videoCaptureLaunchesTheVideoCaptureActivity() {
assertHandledBy(
"android.media.action.VIDEO_CAPTURE",
"ui.activities.VideoCaptureActivity",
)
}

@Test
fun secureStillImageCameraLaunchesTheSecureMainActivity() {
assertHandledBy(
"android.media.action.STILL_IMAGE_CAMERA_SECURE",
"ui.activities.SecureMainActivity",
)
}

@Test
fun secureImageCaptureLaunchesTheSecureCaptureActivity() {
assertHandledBy(
"android.media.action.IMAGE_CAPTURE_SECURE",
"ui.activities.SecureCaptureActivity",
)
}

@Test
fun secureStillImageCameraIsALockscreenEntryPoint() {
assertIsLockscreenEntryPoint(
action = "android.media.action.STILL_IMAGE_CAMERA_SECURE",
expectedAffinity = "SecureMainActivity",
)
}

@Test
fun secureImageCaptureIsALockscreenEntryPoint() {
assertIsLockscreenEntryPoint(
action = "android.media.action.IMAGE_CAPTURE_SECURE",
expectedAffinity = "SecureCaptureActivity",
)
}

@Test
fun theUnlockedEntryPointsDoNotShowOverTheKeyguard() {
// The mirror of the assertions above: were every activity showWhenLocked, they would
// pass while the distinction they exist to protect had been erased.
listOf(
"android.media.action.VIDEO_CAMERA",
"android.media.action.IMAGE_CAPTURE",
"android.media.action.VIDEO_CAPTURE",
).forEach { action ->
val info = activityInfoFor(action)

assertEquals(
"${info.name} answers the non-secure $action and must not show over the" +
" keyguard",
0,
info.flags and FLAG_SHOW_WHEN_LOCKED,
)
}
}

@Test
fun qrTileKeepsTheNameAndFlagsSystemUiDependsOn() {
val info = packageManager.getActivityInfo(
ComponentName(context.packageName, "$PACKAGE.ui.activities.QrTile"),
PackageManager.MATCH_ALL,
)

assertTrue(
"QrTile must stay exported — SystemUI starts it from outside the app",
info.exported,
)
assertTrue(
"QrTile must show over the keyguard; it is a lockscreen shortcut target",
info.flags and FLAG_SHOW_WHEN_LOCKED != 0,
)
assertTrue(
"QrTile must be excluded from recents",
info.flags and ActivityInfo.FLAG_EXCLUDE_FROM_RECENTS != 0,
)
assertNull("QrTile is a real activity, not an alias", info.targetActivity)
}

private companion object {
const val PACKAGE = "app.grapheneos.camera"

// ActivityInfo.FLAG_SHOW_WHEN_LOCKED is @hide
const val FLAG_SHOW_WHEN_LOCKED = 0x800000
}
}
Loading