Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
51 commits
Select commit Hold shift + click to select a range
00376f3
Upgrade targetSdk and compileSdk to 37
RankoR Jul 22, 2026
53580a3
Update .gitignore
RankoR Jul 22, 2026
f2514d3
Migrate to Gradle Version Catalog
RankoR Jul 22, 2026
c220a5d
Sync the androidxc forks with upstream, keeping our changes
RankoR Jul 22, 2026
74bc70f
Update dependencies and adapt to the CameraX 1.6 APIs
RankoR Jul 22, 2026
4fb8750
Update the ProGuard keep rules for the new library versions
RankoR Jul 22, 2026
30ca453
Persist the settings on every exit path, not just the back gesture
RankoR Jul 22, 2026
8e64fd6
Stop printing a stack trace for expected non-numeric input
RankoR Jul 22, 2026
444e3d9
Name the barcode format in the toggle message
RankoR Jul 22, 2026
56267a7
Fall back to the stored timestamp when Exif has been stripped
RankoR Jul 22, 2026
dca0ed3
Label the on-screen controls for accessibility services
RankoR Jul 22, 2026
a171ac0
Keep the control descriptions in sync with the icons they carry
RankoR Jul 22, 2026
3a0b02e
Drop the transitive ACCESS_NETWORK_STATE permission we don't use
RankoR Jul 23, 2026
715e0fc
Handle the window between pressing record and the recording actually …
RankoR Jul 24, 2026
442e121
Bump AGP to 9.3.1
RankoR Jul 24, 2026
5519add
Find the capture button shape inside wrapped drawables instead of cas…
RankoR Jul 24, 2026
9c327b2
Hide the gallery actions until the media they act on has loaded
RankoR Jul 24, 2026
1ffcb44
Close the video player instead of crashing when the media service dies
RankoR Jul 24, 2026
7641e05
Tell the user instead of crashing when a shared file is no longer acc…
RankoR Jul 24, 2026
27b68d5
Tell the user instead of crashing when an edited file is no longer ac…
RankoR Jul 24, 2026
a20703a
Delete the never-enabled file name format settings
RankoR Jul 26, 2026
eb80051
Delete the double-tap gesture detector that only logged and other dea…
RankoR Jul 26, 2026
a76c7e8
Record which upstream release each androidxc fork came from
RankoR Jul 26, 2026
aab43e3
Name the formats saveAttributes really supports in its error message
RankoR Jul 26, 2026
91ae167
Share the wake, wait and recording helpers between the instrumented t…
RankoR Jul 26, 2026
9a77f26
Stop the mode tab strip from acting on a stale scroll position
RankoR Jul 26, 2026
5198961
Keep the mode tab strip in sync with the mode the camera is in
RankoR Jul 26, 2026
ccd162d
Refuse to leave video mode while a recording is running
RankoR Jul 26, 2026
8d86802
Remember the self-timer duration and keep its badge honest
RankoR Jul 26, 2026
dad42aa
Close the settings panel on back instead of sending the camera away
RankoR Jul 26, 2026
d92dac5
Hand the screen brightness back to the system when self-illumination …
RankoR Jul 26, 2026
dc23a33
Recognise the focus timeout Off option through the string array it co…
RankoR Jul 26, 2026
72a7526
Open the keyboard when the photo quality row is tapped
RankoR Jul 26, 2026
58837d0
Label the camera and settings controls for accessibility services
RankoR Jul 26, 2026
a8f921a
Tidy up the gallery's imports, spacing and long lines
RankoR Jul 26, 2026
e518e1c
Show the resolution and duration of the media the gallery details des…
RankoR Jul 26, 2026
b3b6485
Announce a gallery slide for what it holds and drop the stale click s…
RankoR Jul 26, 2026
34c2d95
Release the storage location grants the app has stopped tracking
RankoR Jul 26, 2026
e7a9c0d
Discard a failed recording that never wrote a single byte
RankoR Jul 26, 2026
dac1300
Delete the pending recordings a killed process left behind
RankoR Jul 26, 2026
c216257
Stop asking for location permission the user never asked to give
RankoR Jul 26, 2026
1de7d2d
Keep the extension probe verdicts for the whole process
RankoR Jul 26, 2026
6262c1e
Let shareCapturedItem name the failure it wants reported
RankoR Jul 26, 2026
879d1b4
Refuse to share the last capture out of a secure session
RankoR Jul 26, 2026
34e0f9b
Leave the level indicator alone in QR scan mode
RankoR Jul 26, 2026
b5020ed
Stop a running recording before the unbind in onStop tears it down
RankoR Jul 26, 2026
027d627
Mux recordings with the platform muxer instead of the media3 one that…
RankoR Jul 26, 2026
58eb521
Pick a mode from the tab strip's scroll position only while it is bei…
RankoR Jul 26, 2026
5dd9fc8
Read a cached zoom state instead of asking the camera on every zoom u…
RankoR Jul 26, 2026
ee4830b
Reject out-of-range photo quality values instead of crashing the next…
RankoR Jul 26, 2026
124d62f
Check that the capture button starts the self-timer instead of the sh…
RankoR Jul 27, 2026
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
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,11 @@ captures/
.idea/modules.xml
# Comment next line if keeping position of elements in Navigation Editor is relevant for you
.idea/navEditor.xml
.idea/AndroidProjectSystem.xml
.idea/deploymentTargetSelector.xml
.idea/markdown.xml
.idea/migrations.xml
.idea/runConfigurations.xml

# Keystore files
*.jks
Expand Down
39 changes: 34 additions & 5 deletions .idea/codeStyles/Project.xml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 2 additions & 3 deletions .idea/gradle.xml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion .idea/misc.xml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

37 changes: 19 additions & 18 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ if (useKeystoreProperties) {
}

plugins {
id("com.android.application")
alias(libs.plugins.android.application)
}

java {
Expand Down Expand Up @@ -38,18 +38,20 @@ android {
}
}

compileSdk = 36
buildToolsVersion = "36.1.0"
compileSdk = 37
buildToolsVersion = "37.0.0"
ndkVersion = "29.0.14206865"

namespace = "app.grapheneos.camera"

defaultConfig {
applicationId = "app.grapheneos.camera"
minSdk = 29
targetSdk = 35
targetSdk = 37
versionCode = 90
versionName = versionCode.toString()

testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}

buildTypes {
Expand Down Expand Up @@ -90,18 +92,17 @@ android {
}

dependencies {
implementation("androidx.appcompat:appcompat:1.7.1")
implementation("com.google.android.material:material:1.13.0")
implementation("androidx.constraintlayout:constraintlayout:2.2.1")
implementation("androidx.core:core-ktx:1.17.0")

val cameraVersion = "1.6.0-alpha01"
implementation("androidx.camera:camera-core:$cameraVersion")
implementation("androidx.camera:camera-camera2:$cameraVersion")
implementation("androidx.camera:camera-lifecycle:$cameraVersion")
implementation("androidx.camera:camera-video:$cameraVersion")
implementation("androidx.camera:camera-view:$cameraVersion")
implementation("androidx.camera:camera-extensions:$cameraVersion")

implementation("com.google.zxing:core:3.5.3")
implementation(libs.androidx.appcompat)
implementation(libs.material)
implementation(libs.androidx.constraintlayout)
implementation(libs.androidx.core.ktx)

implementation(libs.bundles.camerax)

implementation(libs.zxing.core)

androidTestImplementation(libs.androidx.test.core.ktx)
androidTestImplementation(libs.androidx.test.ext.junit.ktx)
androidTestImplementation(libs.androidx.test.rules)
androidTestImplementation(libs.androidx.test.runner)
}
13 changes: 10 additions & 3 deletions app/proguard-rules.pro
Original file line number Diff line number Diff line change
@@ -1,4 +1,11 @@
# work around CameraX 1.5.0-alpha05 regression
-keepclassmembers class androidx.camera.camera2.internal.CameraBurstCaptureCallback {
public *;
# ImageSaver reaches androidx.camera.core.internal.utils.ImageUtil#cropJpegByteArray by
# reflection because it is private and copying it out isn't worth the maintenance burden. R8
# does recognize a getDeclaredMethod() call with a constant class, name and parameter list and
# keeps the target, but that inference is the only thing standing between this app and a
# NoSuchMethodException, and it would fail quietly: cropping only runs when a capture is
# actually cropped, so a build that lost the method still takes photos and only loses the
# cropped ones, with an error dialog rather than a crash. State the dependency rather than
# relying on it being inferred.
-keepclassmembers class androidx.camera.core.internal.utils.ImageUtil {
private static byte[] cropJpegByteArray(byte[], android.graphics.Rect, int);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
package app.grapheneos.camera

import android.Manifest
import android.content.Intent
import android.provider.MediaStore
import android.view.ViewTreeObserver
import androidx.test.core.app.ActivityScenario
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import androidx.test.rule.GrantPermissionRule
import app.grapheneos.camera.ui.activities.CaptureActivity
import app.grapheneos.camera.ui.activities.MainActivity
import app.grapheneos.camera.ui.activities.VideoCaptureActivity
import app.grapheneos.camera.ui.activities.VideoOnlyActivity
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertTrue
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import java.util.concurrent.atomic.AtomicInteger

@RunWith(AndroidJUnit4::class)
class CameraModeTabsRegressionTest {

@get:Rule
val grantPermissions: GrantPermissionRule = GrantPermissionRule.grant(
Manifest.permission.CAMERA,
)

@get:Rule
val screenAwake = ScreenAwakeRule()

/**
* The tab strip used to highlight whatever the user last touched, so a mode changed from code
* (a failed extension bind, the QR tile) left it pointing at another mode.
*/
@Test
fun tabStrip_followsAModeChangedFromCode() {
ActivityScenario.launch(MainActivity::class.java).use { scenario ->
awaitModeTabs(scenario)

scenario.onActivity { activity ->
activity.camConfig.switchMode(CameraMode.VIDEO)
assertEquals(CameraMode.VIDEO, activity.tabLayout.selectedTab?.tag)

activity.camConfig.switchMode(CameraMode.CAMERA)
assertEquals(CameraMode.CAMERA, activity.tabLayout.selectedTab?.tag)
}
}
}

/**
* Moving the highlight from within onLayout() made every pass request the next one, pinning the
* main thread at 100% for as long as the activity lived.
*/
@Test
fun tabStrip_settlesAfterAModeChange() {
ActivityScenario.launch(MainActivity::class.java).use { scenario ->
awaitModeTabs(scenario)

scenario.onActivity { it.camConfig.switchMode(CameraMode.VIDEO) }

// Counting passes rather than reading isLayoutRequested: a request issued from inside a
// layout pass is parked for the next traversal and the flag is cleared on the way, so it
// reads false between frames -- exactly where a sample would land.
val passes = AtomicInteger()
val listener = ViewTreeObserver.OnGlobalLayoutListener { passes.incrementAndGet() }
scenario.onActivity { it.tabLayout.viewTreeObserver.addOnGlobalLayoutListener(listener) }
Thread.sleep(WINDOW_MS)
scenario.onActivity {
it.tabLayout.viewTreeObserver.removeOnGlobalLayoutListener(listener)
}

// Rebinding the camera behind the mode change costs one pass. The livelock cost one per
// frame, so anything near this window's 30-60 frames is the regression coming back.
val observed = passes.get()
assertTrue("The tab strip was laid out $observed times in ${WINDOW_MS}ms", observed < 5)
}
}

/**
* The strip was only made transparent there, and a transparent strip still takes taps: a
* disabled parent does not disable its children.
*/
@Test
fun videoOnlyActivity_buildsNoModeTabs() {
ActivityScenario.launch(VideoOnlyActivity::class.java).use { assertNoModeTabs(it) }
}

/**
* A capture session hides the strip but used to keep its tabs, so the swipe handlers -- which
* read the tab model, not the strip -- could still switch modes there.
*/
@Test
fun captureSessions_buildNoModeTabs() {
launchCaptureSession(CaptureActivity::class.java, MediaStore.ACTION_IMAGE_CAPTURE)
.use { assertNoModeTabs(it) }
launchCaptureSession(VideoCaptureActivity::class.java, MediaStore.ACTION_VIDEO_CAPTURE)
.use { assertNoModeTabs(it) }
}

/**
* An app that asked for a single photo got the camera switched to another mode behind its back
* when the user flung the preview, because only the strip was hidden, not the tabs behind it.
*/
@Test
fun flingInACaptureSession_leavesTheModeAlone() {
// Prove the synthetic fling reaches onFling() at all: without this leg the assertion below
// would hold just as well for a fling that never registered.
ActivityScenario.launch(MainActivity::class.java).use { scenario ->
awaitModeTabs(scenario)

scenario.onActivity { activity ->
val tabs = activity.tabLayout
val next = tabs.getTabAt(tabs.selectedTabPosition + 1)
assertNotNull("no mode to the left of ${activity.camConfig.currentMode}", next)

flingLeft(activity)
assertEquals(next!!.tag as CameraMode, activity.camConfig.currentMode)
}
}

launchCaptureSession(CaptureActivity::class.java, MediaStore.ACTION_IMAGE_CAPTURE)
.use { scenario ->
waitUntil(scenario, "camera is bound") { it.camConfig.camera != null }

// Flinging before the tabs were built would pass whether or not any get built
Thread.sleep(TAB_BUILD_DWELL_MS)

scenario.onActivity { activity ->
flingLeft(activity)
assertEquals(CameraMode.CAMERA, activity.camConfig.currentMode)
}
}
}

private fun <A : MainActivity> assertNoModeTabs(scenario: ActivityScenario<A>) {
waitUntil(scenario, "camera is bound") { it.camConfig.camera != null }

// Asserting straight after the bind would also hold while a build was merely still
// pending, so outlast the extension probe round that used to precede one.
Thread.sleep(TAB_BUILD_DWELL_MS)

scenario.onActivity {
assertEquals("${it.javaClass.simpleName} built mode tabs", 0, it.tabLayout.tabCount)
}
}

private fun <A : MainActivity> launchCaptureSession(
activityClass: Class<A>,
action: String,
): ActivityScenario<A> {
val context = InstrumentationRegistry.getInstrumentation().targetContext
return ActivityScenario.launch(Intent(context, activityClass).setAction(action))
}

private companion object {
private const val WINDOW_MS = 500L
private const val TAB_BUILD_DWELL_MS = 2000L
}
}
Loading