Rebuild CameraXFragment to use a brand new camera.

This commit is contained in:
Greyson Parrelli
2026-01-28 16:02:51 -05:00
parent 0c102b061c
commit f53ae66fc9
71 changed files with 5232 additions and 678 deletions

1
demo/camera/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
/build

View File

@@ -0,0 +1,102 @@
plugins {
id("signal-sample-app")
alias(libs.plugins.compose.compiler)
alias(libs.plugins.kotlinx.serialization)
}
android {
namespace = "org.signal.camera.demo"
compileSdkVersion = libs.versions.compileSdk.get()
defaultConfig {
applicationId = "org.signal.camera.demo"
minSdk = 23
targetSdk = libs.versions.targetSdk.get().toInt()
versionCode = 1
versionName = "1.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables {
useSupportLibrary = true
}
}
buildTypes {
release {
isMinifyEnabled = false
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
}
}
compileOptions {
sourceCompatibility = JavaVersion.toVersion(libs.versions.javaVersion.get())
targetCompatibility = JavaVersion.toVersion(libs.versions.javaVersion.get())
}
kotlinOptions {
jvmTarget = libs.versions.kotlinJvmTarget.get()
}
buildFeatures {
compose = true
}
packaging {
resources {
excludes += "/META-INF/{AL2.0,LGPL2.1}"
}
}
}
dependencies {
// Camera feature module
implementation(project(":feature:camera"))
// Core modules
implementation(project(":core:ui"))
// Core AndroidX
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.fragment.ktx)
implementation(libs.androidx.activity.compose)
// Compose BOM
platform(libs.androidx.compose.bom).let { composeBom ->
implementation(composeBom)
androidTestImplementation(composeBom)
}
// Compose dependencies
implementation(libs.androidx.compose.material3)
implementation(libs.androidx.compose.ui.tooling.preview)
debugImplementation(libs.androidx.compose.ui.tooling.core)
debugImplementation(libs.androidx.compose.ui.test.manifest)
implementation(libs.androidx.compose.material.icons.extended)
// Lifecycle
implementation(libs.androidx.lifecycle.runtime.compose)
implementation(libs.androidx.lifecycle.viewmodel.compose)
// Navigation 3
implementation(libs.androidx.navigation3.runtime)
implementation(libs.androidx.navigation3.ui)
// Kotlinx Serialization
implementation(libs.kotlinx.serialization.json)
// Permissions
implementation(libs.accompanist.permissions)
// Image loading via Glide
implementation(libs.glide.glide)
implementation(project(":lib:glide"))
// Media3 for video playback
implementation(libs.bundles.media3)
// Testing
androidTestImplementation(testLibs.junit.junit)
androidTestImplementation(testLibs.androidx.test.runner)
androidTestImplementation(testLibs.androidx.test.ext.junit.ktx)
androidTestImplementation(libs.androidx.compose.ui.test.junit4)
}

21
demo/camera/proguard-rules.pro vendored Normal file
View File

@@ -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

View File

@@ -0,0 +1,24 @@
package org.signal.camera.demo
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("org.signal.camera.demo", appContext.packageName)
}
}

View File

@@ -0,0 +1,39 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-feature android:name="android.hardware.camera" android:required="true" />
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.VIBRATE" />
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
<uses-permission android:name="android.permission.READ_MEDIA_VIDEO" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"
android:maxSdkVersion="32" />
<application
android:name=".CameraDemoApplication"
android:allowBackup="true"
android:dataExtractionRules="@xml/data_extraction_rules"
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.CameraXTest">
<activity
android:name=".MainActivity"
android:exported="true"
android:label="@string/app_name"
android:theme="@style/Theme.CameraXTest">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>

View File

@@ -0,0 +1,25 @@
package org.signal.camera.demo
import android.app.Application
import android.content.Context
import com.bumptech.glide.Glide
import com.bumptech.glide.Registry
import org.signal.core.util.logging.AndroidLogger
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.mms.RegisterGlideComponents
import org.thoughtcrime.securesms.mms.SignalGlideModule
/**
* Application class for the camera demo.
*/
class CameraDemoApplication : Application() {
override fun onCreate() {
super.onCreate()
Log.initialize(AndroidLogger)
SignalGlideModule.registerGlideComponents = object : RegisterGlideComponents {
override fun registerComponents(context: Context, glide: Glide, registry: Registry) {
}
}
}
}

View File

@@ -0,0 +1,27 @@
package org.signal.camera.demo
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.ui.Modifier
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContent {
MaterialTheme {
Surface(
modifier = Modifier.fillMaxSize()
) {
NavGraph()
}
}
}
}
}

View File

@@ -0,0 +1,11 @@
package org.signal.camera.demo
import org.signal.camera.demo.screens.gallery.MediaItem
/**
* Simple singleton to hold the currently selected media item for viewing.
* This is used to pass data between Gallery screen and Viewer screens.
*/
object MediaSelectionHolder {
var selectedMedia: MediaItem? = null
}

View File

@@ -0,0 +1,122 @@
package org.signal.camera.demo
import androidx.compose.animation.AnimatedContentTransitionScope
import androidx.compose.animation.ContentTransform
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInHorizontally
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutHorizontally
import androidx.compose.animation.slideOutVertically
import androidx.compose.animation.togetherWith
import androidx.compose.runtime.Composable
import androidx.navigation3.runtime.NavKey
import androidx.compose.ui.Modifier
import androidx.navigation3.runtime.NavBackStack
import androidx.navigation3.runtime.entryProvider
import androidx.navigation3.runtime.rememberNavBackStack
import androidx.navigation3.scene.Scene
import androidx.navigation3.ui.NavDisplay
import org.signal.camera.demo.screens.gallery.GalleryScreen
import org.signal.camera.demo.screens.imageviewer.ImageViewerScreen
import org.signal.camera.demo.screens.main.MainScreen
import org.signal.camera.demo.screens.videoviewer.VideoViewerScreen
/**
* Navigation destinations as an enum (automatically Parcelable).
*
* To add a new destination:
* 1. Add a new enum value here
* 2. Add a corresponding entry provider in NavGraph.kt
*/
enum class Screen : NavKey {
Main,
Gallery,
ImageViewer,
VideoViewer
}
@Composable
fun NavGraph(
modifier: Modifier = Modifier
) {
val backStack = rememberNavBackStack(Screen.Main)
@Suppress("UNCHECKED_CAST")
val typedBackStack = backStack as NavBackStack<Screen>
NavDisplay(
backStack = backStack,
modifier = modifier,
transitionSpec = {
// Gallery slides up from bottom
slideInHorizontally(
initialOffsetX = { fullWidth -> fullWidth },
animationSpec = tween(500)
) togetherWith
// Camera stays in place and fades out
slideOutHorizontally (
targetOffsetX = { fullWidth -> -fullWidth },
animationSpec = tween(500)
)
},
popTransitionSpec = {
// Camera slides back in from left
slideInHorizontally(
initialOffsetX = { fullWidth -> -fullWidth },
animationSpec = tween(500)
) togetherWith
// Gallery slides out to right
slideOutHorizontally(
targetOffsetX = { fullWidth -> fullWidth },
animationSpec = tween(500)
)
},
predictivePopTransitionSpec = { progress ->
// Camera slides back in from left (predictive with progress)
slideInHorizontally(
initialOffsetX = { fullWidth -> -fullWidth },
animationSpec = tween(500)
) togetherWith
// Gallery slides out to right (predictive with progress)
slideOutHorizontally(
targetOffsetX = { fullWidth -> fullWidth },
animationSpec = tween(500)
)
},
entryProvider = entryProvider {
addEntryProvider(
key = Screen.Main,
contentKey = Screen.Main,
metadata = emptyMap()
) { screen: Screen ->
MainScreen(backStack = typedBackStack)
}
addEntryProvider(
key = Screen.Gallery,
contentKey = Screen.Gallery,
metadata = emptyMap()
) { screen: Screen ->
GalleryScreen(backStack = typedBackStack)
}
addEntryProvider(
key = Screen.ImageViewer,
contentKey = Screen.ImageViewer,
metadata = emptyMap()
) { screen: Screen ->
ImageViewerScreen(backStack = typedBackStack)
}
addEntryProvider(
key = Screen.VideoViewer,
contentKey = Screen.VideoViewer,
metadata = emptyMap()
) { screen: Screen ->
VideoViewerScreen(backStack = typedBackStack)
}
}
)
}

View File

@@ -0,0 +1,222 @@
package org.signal.camera.demo.screens.gallery
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.asPaddingValues
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.systemBars
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation3.runtime.NavBackStack
import org.signal.camera.demo.Screen
import org.signal.glide.compose.GlideImage
import org.signal.glide.compose.GlideImageScaleType
@Composable
fun GalleryScreen(
backStack: NavBackStack<Screen>,
viewModel: GalleryScreenViewModel = viewModel()
) {
val context = LocalContext.current
val state = viewModel.state.value
var showDeleteDialog by remember { mutableStateOf(false) }
LaunchedEffect(Unit) {
viewModel.loadMedia(context)
}
// Delete confirmation dialog
if (showDeleteDialog) {
AlertDialog(
onDismissRequest = { showDeleteDialog = false },
title = { Text("Delete All Media?") },
text = {
Text("This will permanently delete all ${state.mediaItems.size} photos and videos from your gallery. This action cannot be undone.")
},
confirmButton = {
TextButton(
onClick = {
showDeleteDialog = false
viewModel.deleteAllMedia(context)
},
colors = ButtonDefaults.textButtonColors(
contentColor = MaterialTheme.colorScheme.error
)
) {
Text("Delete All")
}
},
dismissButton = {
TextButton(onClick = { showDeleteDialog = false }) {
Text("Cancel")
}
}
)
}
Box(
modifier = Modifier
.fillMaxSize()
.padding(WindowInsets.systemBars.asPaddingValues())
.padding(start = 16.dp, end = 16.dp)
) {
when {
state.isLoading -> {
CircularProgressIndicator(
modifier = Modifier.align(Alignment.Center)
)
}
state.error != null -> {
Text(
text = "Error: ${state.error}",
modifier = Modifier
.align(Alignment.Center)
.padding(16.dp),
color = MaterialTheme.colorScheme.error
)
}
state.mediaItems.isEmpty() -> {
Column(
modifier = Modifier
.align(Alignment.Center)
.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
text = "No photos or videos yet",
style = MaterialTheme.typography.titleMedium
)
Text(
text = "Capture some photos to see them here",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
else -> {
LazyVerticalGrid(
columns = GridCells.Fixed(3),
horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalArrangement = Arrangement.spacedBy(4.dp),
modifier = Modifier.fillMaxSize()
) {
items(state.mediaItems, key = { it.file.absolutePath }) { mediaItem ->
MediaThumbnail(
mediaItem = mediaItem,
onClick = {
org.signal.camera.demo.MediaSelectionHolder.selectedMedia = mediaItem
when (mediaItem) {
is MediaItem.Image -> backStack.add(Screen.ImageViewer)
is MediaItem.Video -> backStack.add(Screen.VideoViewer)
}
}
)
}
}
}
}
// Delete all button at bottom (only show when there are items)
if (state.mediaItems.isNotEmpty()) {
Button(
onClick = { showDeleteDialog = true },
modifier = Modifier
.align(Alignment.BottomCenter)
.fillMaxWidth()
.padding(16.dp),
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.errorContainer,
contentColor = MaterialTheme.colorScheme.onErrorContainer
)
) {
Icon(
imageVector = Icons.Default.Delete,
contentDescription = null
)
Spacer(modifier = Modifier.width(8.dp))
Text("Delete All (${state.mediaItems.size})")
}
}
}
}
@Composable
private fun MediaThumbnail(
mediaItem: MediaItem,
onClick: () -> Unit
) {
Card(
modifier = Modifier
.aspectRatio(1f)
.clickable(onClick = onClick),
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp)
) {
Box(modifier = Modifier.fillMaxSize()) {
GlideImage(
model = mediaItem.file,
scaleType = GlideImageScaleType.CENTER_CROP,
modifier = Modifier.fillMaxSize()
)
if (mediaItem.isVideo) {
Box(
modifier = Modifier
.align(Alignment.Center)
.fillMaxWidth(0.3f)
.aspectRatio(1f)
.background(
color = Color.Black.copy(alpha = 0.5f),
shape = CircleShape
),
contentAlignment = Alignment.Center
) {
Icon(
imageVector = Icons.Default.PlayArrow,
contentDescription = "Video",
modifier = Modifier.fillMaxSize(0.7f),
tint = Color.White
)
}
}
}
}
}

View File

@@ -0,0 +1,6 @@
package org.signal.camera.demo.screens.gallery
sealed interface GalleryScreenEvents {
data class OnMediaItemClick(val mediaItem: MediaItem) : GalleryScreenEvents
data object OnRefresh : GalleryScreenEvents
}

View File

@@ -0,0 +1,7 @@
package org.signal.camera.demo.screens.gallery
data class GalleryScreenState(
val mediaItems: List<MediaItem> = emptyList(),
val isLoading: Boolean = true,
val error: String? = null
)

View File

@@ -0,0 +1,102 @@
package org.signal.camera.demo.screens.gallery
import android.content.Context
import android.util.Log
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.State
import androidx.compose.runtime.mutableStateOf
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.io.File
private const val TAG = "GalleryScreenViewModel"
private const val GALLERY_FOLDER = "gallery"
class GalleryScreenViewModel : ViewModel() {
private val _state: MutableState<GalleryScreenState> = mutableStateOf(GalleryScreenState())
val state: State<GalleryScreenState>
get() = _state
fun loadMedia(context: Context) {
_state.value = _state.value.copy(isLoading = true, error = null)
viewModelScope.launch {
try {
val mediaItems = loadMediaFromInternalStorage(context)
_state.value = _state.value.copy(
mediaItems = mediaItems,
isLoading = false
)
Log.d(TAG, "Loaded ${mediaItems.size} media items")
} catch (e: Exception) {
Log.e(TAG, "Failed to load media: ${e.message}", e)
_state.value = _state.value.copy(
isLoading = false,
error = e.message ?: "Unknown error"
)
}
}
}
fun deleteAllMedia(context: Context) {
viewModelScope.launch {
try {
val count = deleteAllMediaFromInternalStorage(context)
Log.d(TAG, "Deleted $count media items")
// Reload to update UI
loadMedia(context)
} catch (e: Exception) {
Log.e(TAG, "Failed to delete media: ${e.message}", e)
_state.value = _state.value.copy(
error = e.message ?: "Failed to delete media"
)
}
}
}
private suspend fun loadMediaFromInternalStorage(context: Context): List<MediaItem> = withContext(Dispatchers.IO) {
val galleryDir = File(context.filesDir, GALLERY_FOLDER)
if (!galleryDir.exists()) {
return@withContext emptyList()
}
galleryDir.listFiles()
?.filter { it.isFile }
?.mapNotNull { file ->
when {
file.extension.lowercase() in listOf("jpg", "jpeg", "png", "webp") -> {
MediaItem.Image(file)
}
file.extension.lowercase() in listOf("mp4", "mkv", "webm", "mov") -> {
MediaItem.Video(file)
}
else -> null
}
}
?.sortedByDescending { it.lastModified }
?: emptyList()
}
private suspend fun deleteAllMediaFromInternalStorage(context: Context): Int = withContext(Dispatchers.IO) {
val galleryDir = File(context.filesDir, GALLERY_FOLDER)
if (!galleryDir.exists()) {
return@withContext 0
}
val files = galleryDir.listFiles() ?: return@withContext 0
var deletedCount = 0
files.forEach { file ->
if (file.isFile && file.delete()) {
deletedCount++
}
}
deletedCount
}
}

View File

@@ -0,0 +1,24 @@
package org.signal.camera.demo.screens.gallery
import java.io.File
sealed class MediaItem {
abstract val file: File
abstract val name: String
abstract val lastModified: Long
data class Image(
override val file: File,
override val name: String = file.name,
override val lastModified: Long = file.lastModified()
) : MediaItem()
data class Video(
override val file: File,
override val name: String = file.name,
override val lastModified: Long = file.lastModified()
) : MediaItem()
val isImage: Boolean get() = this is Image
val isVideo: Boolean get() = this is Video
}

View File

@@ -0,0 +1,101 @@
package org.signal.camera.demo.screens.imageviewer
import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.detectTransformGestures
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.asPaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.systemBars
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.unit.dp
import androidx.navigation3.runtime.NavBackStack
import org.signal.camera.demo.Screen
import org.signal.camera.demo.screens.gallery.MediaItem
import org.signal.glide.compose.GlideImage
import org.signal.glide.compose.GlideImageScaleType
@Composable
fun ImageViewerScreen(
backStack: NavBackStack<Screen>
) {
val selectedMedia = org.signal.camera.demo.MediaSelectionHolder.selectedMedia
if (selectedMedia == null || selectedMedia !is MediaItem.Image) {
// No image selected, go back
backStack.removeLastOrNull()
return
}
val imageFile = selectedMedia.file
var scale by remember { mutableFloatStateOf(1f) }
var offset by remember { mutableStateOf(Offset.Zero) }
Box(
modifier = Modifier
.fillMaxSize()
.padding(WindowInsets.systemBars.asPaddingValues())
.background(Color.Black)
) {
// Image with pinch-to-zoom
Box(
modifier = Modifier
.fillMaxSize()
.pointerInput(Unit) {
detectTransformGestures { _, pan, zoom, _ ->
scale = (scale * zoom).coerceIn(1f, 5f)
if (scale > 1f) {
offset += pan
} else {
offset = Offset.Zero
}
}
},
contentAlignment = Alignment.Center
) {
GlideImage(
model = imageFile,
scaleType = GlideImageScaleType.FIT_CENTER,
modifier = Modifier
.fillMaxSize()
.graphicsLayer(
scaleX = scale,
scaleY = scale,
translationX = offset.x,
translationY = offset.y
)
)
}
// Back button
IconButton(
onClick = { backStack.removeLastOrNull() },
modifier = Modifier
.align(Alignment.TopStart)
.padding(8.dp)
) {
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = "Back",
tint = Color.White
)
}
}
}

View File

@@ -0,0 +1,220 @@
@file:OptIn(ExperimentalPermissionsApi::class)
package org.signal.camera.demo.screens.main
import android.Manifest
import android.os.Build
import android.widget.Toast
import androidx.compose.foundation.layout.Box
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.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.tooling.preview.PreviewLightDark
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.google.accompanist.permissions.MultiplePermissionsState
import com.google.accompanist.permissions.PermissionState
import com.google.accompanist.permissions.rememberMultiplePermissionsState
import androidx.compose.foundation.background
import androidx.compose.ui.graphics.Color
import androidx.navigation3.runtime.NavBackStack
import org.signal.camera.demo.Screen
import org.signal.camera.hud.StandardCameraHudEvents
import org.signal.camera.CameraScreen
import org.signal.camera.CameraScreenEvents
import org.signal.camera.CameraScreenViewModel
import org.signal.camera.hud.StandardCameraHud
@Composable
fun MainScreen(
backStack: NavBackStack<Screen>,
viewModel: MainScreenViewModel = viewModel(),
) {
val cameraViewModel: CameraScreenViewModel = viewModel()
val permissions = buildList {
add(Manifest.permission.CAMERA)
add(Manifest.permission.RECORD_AUDIO)
if (Build.VERSION.SDK_INT >= 33) {
add(Manifest.permission.READ_MEDIA_IMAGES)
add(Manifest.permission.READ_MEDIA_VIDEO)
} else {
add(Manifest.permission.READ_EXTERNAL_STORAGE)
}
}
val permissionsState = rememberMultiplePermissionsState(permissions = permissions)
val context = LocalContext.current
var qrCodeContent by remember { mutableStateOf<String?>(null) }
LaunchedEffect(Unit) {
permissionsState.launchMultiplePermissionRequest()
}
// Observe save status and show toasts
LaunchedEffect(viewModel.state.value.saveStatus) {
viewModel.state.value.saveStatus?.let { status ->
val message = when (status) {
is SaveStatus.Saving -> null
is SaveStatus.Success -> "Saved to gallery!"
is SaveStatus.Error -> "Failed to save: ${status.message}"
}
message?.let {
Toast.makeText(context, it, Toast.LENGTH_SHORT).show()
viewModel.onEvent(MainScreenEvents.ClearSaveStatus)
}
}
}
// Observe QR code detections from the camera view model
LaunchedEffect(cameraViewModel) {
cameraViewModel.qrCodeDetected.collect { qrCode ->
qrCodeContent = qrCode
}
}
when {
permissionsState.allPermissionsGranted -> {
Box(
modifier = Modifier
.fillMaxSize()
.background(Color.Black),
contentAlignment = Alignment.Center
) {
CameraScreen(
state = cameraViewModel.state.value,
emitter = { event -> cameraViewModel.onEvent(event) }
) {
StandardCameraHud(
state = cameraViewModel.state.value,
emitter = { event ->
when (event) {
is StandardCameraHudEvents.PhotoCaptureTriggered -> {
cameraViewModel.capturePhoto(
context = context,
onPhotoCaptured = { bitmap ->
viewModel.onEvent(MainScreenEvents.SavePhoto(context, bitmap))
}
)
}
is StandardCameraHudEvents.VideoCaptureStarted -> {
cameraViewModel.startRecording(
context = context,
output = viewModel.createVideoOutput(context),
onVideoCaptured = { result ->
viewModel.onEvent(MainScreenEvents.VideoSaved(result))
}
)
}
is StandardCameraHudEvents.VideoCaptureStopped-> {
cameraViewModel.stopRecording()
}
is StandardCameraHudEvents.GalleryClick -> {
backStack.add(Screen.Gallery)
}
is StandardCameraHudEvents.ToggleFlash -> {
cameraViewModel.onEvent(CameraScreenEvents.NextFlashMode)
}
is StandardCameraHudEvents.ClearCaptureError -> {
cameraViewModel.onEvent(CameraScreenEvents.ClearCaptureError)
}
is StandardCameraHudEvents.SwitchCamera -> {
cameraViewModel.onEvent(CameraScreenEvents.SwitchCamera(context))
}
is StandardCameraHudEvents.SetZoomLevel -> {
cameraViewModel.onEvent(CameraScreenEvents.LinearZoom(event.zoomLevel))
}
is StandardCameraHudEvents.MediaSelectionClick -> {
// Doesn't need to be handled
}
}
}
)
}
}
// QR Code Dialog
if (qrCodeContent != null) {
AlertDialog(
onDismissRequest = { qrCodeContent = null },
title = { Text("QR Code Detected") },
text = { Text(qrCodeContent ?: "") },
confirmButton = {
TextButton(onClick = { qrCodeContent = null }) {
Text("OK")
}
}
)
}
}
else -> {
PermissionDeniedContent(permissionsState)
}
}
}
@Composable
fun PermissionDeniedContent(permissionsState: MultiplePermissionsState) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.padding(16.dp)
) {
Text(
text = "Camera, microphone, and media permissions are required",
style = MaterialTheme.typography.bodyLarge
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "Media permissions allow showing your recent photos in the gallery button",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(16.dp))
Button(onClick = { permissionsState.launchMultiplePermissionRequest() }) {
Text("Grant Permissions")
}
}
}
}
@PreviewLightDark
@Composable
fun PreviewPermissionDeniedLight() {
MaterialTheme {
Surface(modifier = Modifier.fillMaxSize()) {
PermissionDeniedContent(permissionsState = PreviewMultiplePermissionsState())
}
}
}
private class PreviewMultiplePermissionsState : MultiplePermissionsState {
override val allPermissionsGranted: Boolean = false
override val permissions: List<PermissionState> = emptyList()
override val revokedPermissions: List<PermissionState> = emptyList()
override val shouldShowRationale: Boolean = false
override fun launchMultiplePermissionRequest() {}
}

View File

@@ -0,0 +1,12 @@
package org.signal.camera.demo.screens.main
import android.content.Context
import android.graphics.Bitmap
sealed interface MainScreenEvents {
data class SavePhoto(val context: Context, val bitmap: Bitmap) : MainScreenEvents
data class VideoSaved(val result: org.signal.camera.VideoCaptureResult) : MainScreenEvents
data object ClearSaveStatus : MainScreenEvents
}

View File

@@ -0,0 +1,11 @@
package org.signal.camera.demo.screens.main
data class MainScreenState(
val saveStatus: SaveStatus? = null
)
sealed interface SaveStatus {
data object Saving : SaveStatus
data object Success : SaveStatus
data class Error(val message: String?) : SaveStatus
}

View File

@@ -0,0 +1,126 @@
package org.signal.camera.demo.screens.main
import android.content.Context
import android.graphics.Bitmap
import android.os.ParcelFileDescriptor
import android.util.Log
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.State
import androidx.compose.runtime.mutableStateOf
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import org.signal.camera.VideoCaptureResult
import org.signal.camera.VideoOutput
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.io.File
import java.text.SimpleDateFormat
import java.util.Locale
private const val TAG = "MainScreenViewModel"
private const val GALLERY_FOLDER = "gallery"
class MainScreenViewModel : ViewModel() {
private val _state: MutableState<MainScreenState> = mutableStateOf(MainScreenState())
val state: State<MainScreenState>
get() = _state
fun onEvent(event: MainScreenEvents) {
val currentState = _state.value
when (event) {
is MainScreenEvents.SavePhoto -> {
handleSavePhotoEvent(currentState, event)
}
is MainScreenEvents.VideoSaved -> {
handleVideoSavedEvent(currentState, event)
}
is MainScreenEvents.ClearSaveStatus -> {
handleClearSaveStatusEvent(currentState, event)
}
}
}
fun createVideoOutput(context: Context): VideoOutput {
// Create gallery directory in internal storage
val galleryDir = File(context.filesDir, GALLERY_FOLDER)
if (!galleryDir.exists()) {
galleryDir.mkdirs()
}
// Generate filename with timestamp
val name = SimpleDateFormat("yyyy-MM-dd-HH-mm-ss-SSS", Locale.US)
.format(System.currentTimeMillis())
val file = File(galleryDir, "$name.mp4")
// Open the file as a ParcelFileDescriptor
val fileDescriptor = ParcelFileDescriptor.open(
file,
ParcelFileDescriptor.MODE_READ_WRITE or ParcelFileDescriptor.MODE_CREATE
)
return VideoOutput.FileDescriptorOutput(fileDescriptor)
}
private fun handleSavePhotoEvent(
state: MainScreenState,
event: MainScreenEvents.SavePhoto
) {
_state.value = state.copy(saveStatus = SaveStatus.Saving)
viewModelScope.launch {
try {
saveBitmapToMediaStore(event)
_state.value = _state.value.copy(saveStatus = SaveStatus.Success)
} catch (e: Exception) {
Log.e(TAG, "Failed to save photo: ${e.message}", e)
_state.value = _state.value.copy(saveStatus = SaveStatus.Error(e.message))
}
}
}
private fun handleVideoSavedEvent(
state: MainScreenState,
event: MainScreenEvents.VideoSaved
) {
when (val result = event.result) {
is VideoCaptureResult.Success -> {
// Close the file descriptor now that recording is complete
result.fileDescriptor?.close()
Log.d(TAG, "Video saved successfully")
_state.value = state.copy(saveStatus = SaveStatus.Success)
}
is VideoCaptureResult.Error -> {
Log.e(TAG, "Failed to save video: ${result.message}", result.throwable)
_state.value = state.copy(saveStatus = SaveStatus.Error(result.message))
}
}
}
private fun handleClearSaveStatusEvent(
state: MainScreenState,
event: MainScreenEvents.ClearSaveStatus
) {
_state.value = state.copy(saveStatus = null)
}
private suspend fun saveBitmapToMediaStore(event: MainScreenEvents.SavePhoto) = withContext(Dispatchers.IO) {
// Create gallery directory in internal storage
val galleryDir = File(event.context.filesDir, "gallery")
if (!galleryDir.exists()) {
galleryDir.mkdirs()
}
// Generate filename with timestamp
val name = SimpleDateFormat("yyyy-MM-dd-HH-mm-ss-SSS", Locale.US)
.format(System.currentTimeMillis())
val file = File(galleryDir, "$name.jpg")
// Save bitmap to file
file.outputStream().use { outputStream ->
event.bitmap.compress(Bitmap.CompressFormat.JPEG, 95, outputStream)
Log.d(TAG, "Photo saved to internal storage: ${file.absolutePath}")
}
}
}

View File

@@ -0,0 +1,99 @@
package org.signal.camera.demo.screens.videoviewer
import android.view.ViewGroup
import android.widget.FrameLayout
import androidx.annotation.OptIn
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.media3.common.MediaItem
import androidx.media3.common.Player
import androidx.media3.common.util.UnstableApi
import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.ui.PlayerView
import androidx.navigation3.runtime.NavBackStack
import org.signal.camera.demo.Screen
import java.io.File
@OptIn(UnstableApi::class)
@Composable
fun VideoViewerScreen(
backStack: NavBackStack<Screen>
) {
val selectedMedia = org.signal.camera.demo.MediaSelectionHolder.selectedMedia
if (selectedMedia == null || selectedMedia !is org.signal.camera.demo.screens.gallery.MediaItem.Video) {
// No video selected, go back
backStack.removeAt(backStack.lastIndex)
return
}
val context = LocalContext.current
val videoFile = selectedMedia.file
val exoPlayer = remember {
ExoPlayer.Builder(context).build().apply {
val mediaItem = MediaItem.fromUri(videoFile.toURI().toString())
setMediaItem(mediaItem)
prepare()
playWhenReady = true
repeatMode = Player.REPEAT_MODE_OFF
}
}
DisposableEffect(Unit) {
onDispose {
exoPlayer.release()
}
}
Box(
modifier = Modifier
.fillMaxSize()
.background(Color.Black)
) {
// Video player
AndroidView(
factory = { ctx ->
PlayerView(ctx).apply {
player = exoPlayer
layoutParams = FrameLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.MATCH_PARENT
)
useController = true
controllerShowTimeoutMs = 3000
}
},
modifier = Modifier.fillMaxSize()
)
// Back button
IconButton(
onClick = { backStack.removeAt(backStack.lastIndex) },
modifier = Modifier
.align(Alignment.TopStart)
.padding(8.dp)
) {
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = "Back",
tint = Color.White
)
}
}
}

View File

@@ -0,0 +1,11 @@
package org.signal.camera.demo.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)

View File

@@ -0,0 +1,58 @@
package org.signal.camera.demo.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 CameraXTestTheme(
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
)
}

View File

@@ -0,0 +1,34 @@
package org.signal.camera.demo.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
)
*/
)

View File

@@ -0,0 +1,171 @@
<?xml version="1.0" encoding="utf-8"?>
<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="#3DDC84"
android:pathData="M0,0h108v108h-108z" />
<path
android:fillColor="#00000000"
android:pathData="M9,0L9,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,0L19,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M29,0L29,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M39,0L39,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M49,0L49,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M59,0L59,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M69,0L69,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M79,0L79,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M89,0L89,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M99,0L99,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,9L108,9"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,19L108,19"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,29L108,29"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,39L108,39"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,49L108,49"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,59L108,59"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,69L108,69"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,79L108,79"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,89L108,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,99L108,99"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,29L89,29"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,39L89,39"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,49L89,49"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,59L89,59"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,69L89,69"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,79L89,79"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M29,19L29,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M39,19L39,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M49,19L49,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M59,19L59,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M69,19L69,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M79,19L79,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
</vector>

View File

@@ -0,0 +1,31 @@
<vector
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path android:pathData="M31,63.928c0,0 6.4,-11 12.1,-13.1c7.2,-2.6 26,-1.4 26,-1.4l38.1,38.1L107,108.928l-32,-1L31,63.928z">
<aapt:attr name="android:fillColor">
<gradient
android:endX="85.84757"
android:endY="92.4963"
android:startX="42.9492"
android:startY="49.59793"
android:type="linear">
<item
android:color="#44000000"
android:offset="0.0" />
<item
android:color="#00000000"
android:offset="1.0" />
</gradient>
</aapt:attr>
</path>
<path
android:fillColor="#FFFFFF"
android:fillType="nonZero"
android:pathData="M65.3,45.828l3.8,-6.6c0.2,-0.4 0.1,-0.9 -0.3,-1.1c-0.4,-0.2 -0.9,-0.1 -1.1,0.3l-3.9,6.7c-6.3,-2.8 -13.4,-2.8 -19.7,0l-3.9,-6.7c-0.2,-0.4 -0.7,-0.5 -1.1,-0.3C38.8,38.328 38.7,38.828 38.9,39.228l3.8,6.6C36.2,49.428 31.7,56.028 31,63.928h46C76.3,56.028 71.8,49.428 65.3,45.828zM43.4,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2c-0.3,-0.7 -0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C45.3,56.528 44.5,57.328 43.4,57.328L43.4,57.328zM64.6,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2s-0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C66.5,56.528 65.6,57.328 64.6,57.328L64.6,57.328z"
android:strokeWidth="1"
android:strokeColor="#00000000" />
</vector>

View File

@@ -0,0 +1,7 @@
<?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>

View File

@@ -0,0 +1,7 @@
<?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>

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 982 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="purple_200">#FFBB86FC</color>
<color name="purple_500">#FF6200EE</color>
<color name="purple_700">#FF3700B3</color>
<color name="teal_200">#FF03DAC5</color>
<color name="teal_700">#FF018786</color>
<color name="black">#FF000000</color>
<color name="white">#FFFFFFFF</color>
</resources>

View File

@@ -0,0 +1,3 @@
<resources>
<string name="app_name">CameraXTest</string>
</resources>

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="Theme.CameraXTest" parent="android:Theme.Material.Light.NoActionBar" />
</resources>

View File

@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?><!--
Sample backup rules file; uncomment and customize as necessary.
See https://developer.android.com/guide/topics/data/autobackup
for details.
Note: This file is ignored for devices older than API 31
See https://developer.android.com/about/versions/12/backup-restore
-->
<full-backup-content>
<!--
<include domain="sharedpref" path="."/>
<exclude domain="sharedpref" path="device.xml"/>
-->
</full-backup-content>

View File

@@ -0,0 +1,19 @@
<?xml version="1.0" encoding="utf-8"?><!--
Sample data extraction rules file; uncomment and customize as necessary.
See https://developer.android.com/about/versions/12/backup-restore#xml-changes
for details.
-->
<data-extraction-rules>
<cloud-backup>
<!-- TODO: Use <include> and <exclude> to control what is backed up.
<include .../>
<exclude .../>
-->
</cloud-backup>
<!--
<device-transfer>
<include .../>
<exclude .../>
</device-transfer>
-->
</data-extraction-rules>

View File

@@ -0,0 +1,17 @@
package org.signal.camera.demo
import org.junit.Test
import org.junit.Assert.*
/**
* Example local unit test, which will execute on the development machine (host).
*
* See [testing documentation](http://d.android.com/tools/testing).
*/
class ExampleUnitTest {
@Test
fun addition_isCorrect() {
assertEquals(4, 2 + 2)
}
}