A calm, private, offline-inspired photo gallery for Android. Device photos become tilted polaroid cards on a paper wall, grouped by month. Built native in Kotlin + Jetpack Compose.
Package: com.memorywall · Database: memory_wall.db · No backend — fully local.
This repository implements Phase 1 (Foundation) and Phase 2 (Memory Wall) from
intro.md. The Memory Wall is the app's default home screen and core product
experience; everything else (detail, archive, search, collections) consumes the same photo stream.
| Principle | How it works |
|---|---|
| Photos stay on device | MediaStore is read live. Nothing is copied into app storage. |
| Metadata is app-owned | Favorite, pin, hide, archive, title, and note live in Room (photo_meta), keyed by MediaStore id. |
| Missing row = defaults | No metadata row means not favorite, not hidden, not archived, not pinned. |
| Delete is explicit | Only user-confirmed delete touches real files, via the system MediaStore delete flow. |
The Memory Wall is a staggered grid of polaroid cards pinned to a paper surface. Cards are interleaved with month ribbon separators so the wall reads as a timeline. It is the first tab in the bottom navigation bar and the screen users land on after onboarding.
flowchart TD
Splash --> Onboarding --> Permission --> Main
Main --> WallTab
Main --> CollectionsTab
Main --> SearchTab
Main --> SettingsTab
WallTab -->|tap card| Detail
WallTab -->|long press| MultiSelect
MultiSelect -->|hide or archive| ArchiveView
SettingsTab --> Archive
| Interaction | Behavior |
|---|---|
| Default tab | Tab.WALL — first bottom-nav slot; title bar shows "memory wall" when not selecting |
| Grid | LazyVerticalStaggeredGrid with 2–3 columns (setting: gridColumns) |
| Timeline | Month separators ("June 2026") interleaved with cards via WallGrouping |
| Sort | By dateTakenMillis; newest-first by default, oldest-first via General settings |
| Filters | Chips: all, favorites, pinned (WallFilter) |
| Tap card | Opens photo detail (Routes.DETAIL) |
| Long-press | Enters multi-select; bottom bar swaps from AppBottomBar to MultiSelectBar |
| Tap in selection | Toggles selection on/off |
| Pull-to-refresh | Forces MediaStore re-query via PhotoRepository.refresh() |
| Search icon | Top-bar icon switches to the Search tab |
| Permission denied | In-wall recovery UI with allow access button |
| Empty library | no memories yet — photos on device appear automatically as polaroids |
| Empty filter | Filter-specific empty state when favorites/pinned yield no cards |
Photos with hidden or archived metadata are excluded from the wall immediately. They appear in
Settings → Archive and can be restored (unhide / unarchive) to return to the wall.
The wall follows an analog, paper-and-polaroid aesthetic. Visual behavior is deterministic per photo so cards never jump when scrolling, recomposing, or after process death.
PolaroidCard.kt renders each
WallCard:
- White card stock with soft paper shadow
- Image loaded via Coil from the MediaStore URI
- Caption line (date or user title — see settings)
- Favorite and pin badges when applicable
- Selection overlay (checkmark) in multi-select mode
- Aspect ratio derived from photo dimensions (clamped to a natural range)
PaperSurface.kt— paper backgroundTheme.kt—MemoryWallTheme,AnalogColors(ink, paper, tape palette)- Light / dark / system theme via
themeModesetting
StickyTape.kt draws tape strips on
some cards. Placement is deterministic (TapeStyle: none, center, left, right). Can be disabled
globally in Settings → Card Styles.
CardStyle.kt derives rotation and tape
from the MediaStore id — no Random. Base rotation is roughly ±4° from a fixed angle table.
User tilt intensity multiplies the base angle via CardStyleConfig.kt:
CardTilt |
tiltFactor |
|---|---|
| OFF | 0 |
| SUBTLE | 0.45 |
| LIVELY | 1.0 |
LocalCardStyle is provided app-wide from MemoryWallRoot so every PolaroidCard picks up
settings without per-screen parameters.
MonthSeparator.kt renders
ribbon-style month headers between card groups. Keys use YYYY-MM for stable ordering; labels use
locale-aware "MMMM yyyy" formatting.
Pattern: MVVM + Repository. Hilt for DI, Navigation Compose for routing, Coil for images, Room for metadata, DataStore for settings.
flowchart LR
MediaStore --> MediaStorePhotoSource
MediaStorePhotoSource -->|ContentObserver| PhotoRepository
RoomPhotoMeta --> PhotoRepository
PhotoRepository -->|Flow List WallCard| WallViewModel
SettingsDataStore --> WallViewModel
WallViewModel --> WallScreen
WallViewModel -->|bulk actions| MetadataRepository
MetadataRepository --> RoomPhotoMeta
PhotoRepository combines live MediaStore photos with Room metadata into WallCard objects:
fun observeCards(): Flow<List<WallCard>> =
combine(photos, metaDao.observeAll()) { list, metas ->
val byId = metas.associateBy { it.mediaId }
list.map { p -> WallCard(p, byId[p.id]?.toDomain() ?: PhotoMeta(p.id)) }
}Source: PhotoRepository.kt
WallViewModel.kt combines four
streams into WallUiState:
photoRepository.observeCards()— all cards (includes archived/hidden in raw list)selectedIds— multi-select statefilter—WallFilter(all / favorites / pinned)settingsRepository.settings— grid columns, sort direction, confirm-delete
Processing steps:
- Visibility — drop
archivedandhiddencards - Filter — apply favorites or pinned filter when active
- Grouping —
WallGrouping.group()flattens intoWallListItem(month headers + cards)
MediaStorePhotoSource
registers a ContentObserver on MediaStore.Images.Media.EXTERNAL_CONTENT_URI. Library changes
trigger automatic re-query. Pull-to-refresh bumps an internal refreshTrigger for explicit
re-query.
WallViewModel is scoped at MainScreen
(not inside WallScreen) so:
- Selection state persists across tab switches
MainScreencan swap the bottom bar betweenAppBottomBarandMultiSelectBar- Delete, share, and add-to-collection flows stay in one place
Defined in Photo.kt.
Live device photo from MediaStore. Never stored in Room.
| Field | Source |
|---|---|
id |
MediaStore _ID |
uri |
Content URI |
displayName |
File name |
dateTakenMillis |
DATE_TAKEN (used for timeline grouping) |
dateAddedMillis |
DATE_ADDED |
mimeType, width, height, sizeBytes |
MediaStore columns |
bucketName |
Album/folder name (API 29+) |
App-owned metadata. Source of truth for user intent.
| Field | Default | Purpose |
|---|---|---|
favorite |
false |
Heart badge; favorites filter |
pinned |
false |
Pin badge; pinned filter |
hidden |
false |
Removed from wall; visible in Archive |
archived |
false |
Removed from wall; visible in Archive |
title |
null |
Optional caption when CaptionStyle.TITLE |
note |
null |
Free-text note (detail view; not shown on wall card) |
Join of Photo + PhotoMeta. The unit the wall grid renders.
Entities.kt — table photo_meta:
@Entity(tableName = "photo_meta")
data class PhotoMetaEntity(
@PrimaryKey val mediaId: Long,
val favorite: Boolean = false,
val archived: Boolean = false,
val hidden: Boolean = false,
val pinned: Boolean = false,
val title: String? = null,
val note: String? = null,
...
)| Metadata state | On wall? | Notes |
|---|---|---|
| Default (no row) | Yes | All flags false |
| Favorite | Yes | Heart badge; favorites filter |
| Pinned | Yes | Pin badge; pinned filter |
| Hidden | No | Restore via Archive |
| Archived | No | Restore via Archive |
WallGrouping.kt flattens filtered
cards into a list of WallListItem:
MonthHeader(key, label)— e.g. key2026-06, labelJune 2026Card(card)— theWallCarditself
Grouping uses dateTakenMillis and the device timezone. Cards are sorted defensively before
interleaving so headers stay correct even if input order varies. headerKeys() is pure and
Android-free — used by unit tests.
- Long-press a card →
startSelection(id)— one card selected, selection mode active - Bottom bar swaps to
MultiSelectBar - Top bar shows count and close button
- Filter chips hide during selection
| Action | Handler | Clears selection? |
|---|---|---|
| Collect | Opens AddToCollectionSheet with selected mediaIds |
On done |
| Favorite | metadataRepository.setFavorite(ids, true) |
No |
| Pin | metadataRepository.setPinned(ids, true) |
No |
| Hide | metadataRepository.setHidden(ids, true) |
Yes |
| Archive | metadataRepository.setArchived(ids, true) |
Yes |
| Share | Intent.ACTION_SEND / SEND_MULTIPLE with selected URIs |
No |
| Delete | MediaStore delete request (see below) | Yes (on confirm) |
- User taps delete → optional confirmation dialog if
confirmDeleteis true PhotoRepository.requestDelete(uris)→MediaStorePhotoSource.requestDelete()- API 30+:
MediaStore.createDeleteRequest→NeedsConsent→ system UI viaIntentSender - Legacy: direct delete or
RecoverableSecurityExceptionconsent - On success:
onDeleteConfirmed(ids)→forgetMeta(ids)removes stale Room rows
Handled in MainScreen.kt with
ActivityResultContracts.StartIntentSenderForResult.
Stored in DataStore (settings preferences) via
SettingsRepository.
| Setting | Default | Where to change | Effect on wall |
|---|---|---|---|
gridColumns |
2 |
Settings → General | Staggered grid columns (2–3) |
oldestFirst |
false |
Settings → General | Sort direction in WallGrouping |
cardTilt |
LIVELY |
Settings → Card Styles | Tilt multiplier (OFF / SUBTLE / LIVELY) |
cardTape |
true |
Settings → Card Styles | Show or hide sticky tape |
captionStyle |
TITLE |
Settings → Card Styles | Caption shows date or user title |
confirmDelete |
true |
Settings → General | Delete confirmation dialog |
themeMode |
SYSTEM |
Settings | Light / dark / system paper theme |
Caption logic in PolaroidCard: when CaptionStyle.TITLE, shows meta.title if set, otherwise
falls back to the short date.
Centralized in PhotoPermissions.kt.
| API level | Permissions requested |
|---|---|
| 34+ (Android 14) | READ_MEDIA_IMAGES, READ_MEDIA_VISUAL_USER_SELECTED (partial / selected photos) |
| 33 (Android 13) | READ_MEDIA_IMAGES |
| ≤32 | READ_EXTERNAL_STORAGE |
hasAccess() returns true if any required permission is granted — supporting both full
library access and partial "selected photos" on Android 14+.
Declared in AndroidManifest.xml:
WRITE_EXTERNAL_STORAGEwithmaxSdkVersion="28"for legacy delete only- Modern deletes use MediaStore consent — no write permission declared for API 29+
- First launch — dedicated
PermissionScreenafter onboarding - Wall recovery — if access is denied while on the wall,
PermissionRecoveryUI replaces the grid - Re-grant —
LaunchedEffect(hasAccess)callsviewModel.refresh()when access returns
All screens below consume PhotoRepository.observeCards() — a single source of truth.
- Entry: tap a card on the wall
- Route:
Routes.DETAIL/{photoId}(outer nav graph) - Behavior: front polaroid with pinch-zoom fullscreen viewer, swipe between photos in the current filtered set, flip for EXIF back
- Files:
PhotoDetailScreen.kt,PhotoDetailViewModel.kt
- Entry: Settings → Archive
- Shows: hidden and archived cards
- Restore: unhide / unarchive returns photos to the wall
- Files:
ArchiveScreen.kt
- Entry: search icon in wall top bar (switches to Search tab) or Search tab directly
- Behavior: text query and tag filters over the same card pool; results reuse
PolaroidCard - Files:
SearchScreen.kt,SearchViewModel.kt
- Entry: multi-select collect on wall, or Collections tab
- Add from wall:
AddToCollectionSheetreceives selectedmediaIds - Collection detail: reuses
PolaroidCardlayout - Files:
CollectionsScreen.kt,CollectionDetailScreen.kt,AddToCollectionSheet.kt
The bottom bar center button opens a sheet explaining that device photos already appear on the wall automatically. Import, camera capture, and expanded collection flows are planned for later phases.
Before the wall appears, users pass through:
| Screen | File | Purpose |
|---|---|---|
| Splash | SplashScreen.kt |
Route to onboarding, permission, or main |
| Onboarding | OnboardingScreen.kt |
3-page intro |
| Permission | PermissionScreen.kt |
Initial photo access request |
| Main | MainScreen.kt |
Tab host; wall is startDestination |
Outer navigation graph: MemoryWallRoot.kt
Bottom bar tabs (Destinations.kt):
| Tab | Route | Label |
|---|---|---|
| Wall | tab_wall |
wall |
| Collections | tab_collections |
collections |
| Search | tab_search |
search |
| Settings | tab_settings |
settings |
Memory Wall–focused layout under app/src/main/java/com/memorywall/:
com.memorywall
├── MainActivity.kt # setContent { MemoryWallRoot() }
├── MemoryWallApp.kt # Hilt application
├── di/AppModule.kt # Room, DAOs, IO dispatcher
│
├── domain/model/
│ └── Photo.kt # Photo, PhotoMeta, WallCard, ExifData
│
├── data/
│ ├── media/MediaStorePhotoSource.kt # Query, ContentObserver, EXIF, delete
│ ├── local/
│ │ ├── Entities.kt # photo_meta (+ scaffolded collection/tag tables)
│ │ ├── PhotoMetaDao.kt
│ │ └── AppDatabase.kt # memory_wall.db
│ ├── repository/
│ │ ├── PhotoRepository.kt # observeCards(), refresh(), delete
│ │ └── MetadataRepository.kt# favorite, pin, hide, archive writes
│ └── prefs/SettingsRepository.kt # DataStore user settings
│
└── ui/
├── MemoryWallRoot.kt # Outer nav + LocalCardStyle provider
├── main/MainScreen.kt # Tab host, VM ownership, delete/share/collect
├── navigation/
│ ├── Destinations.kt # Routes, Tab enum
│ └── BottomBar.kt # AppBottomBar
├── wall/
│ ├── WallScreen.kt # Grid, filters, empty states, permissions
│ ├── WallViewModel.kt # State, filtering, selection, bulk actions
│ ├── WallGrouping.kt # Month header interleaving
│ ├── CardStyle.kt # Deterministic tilt/tape
│ └── MultiSelectBar.kt # Bottom action bar
├── components/
│ ├── PolaroidCard.kt
│ ├── MonthSeparator.kt
│ ├── StickyTape.kt
│ └── PaperSurface.kt
├── theme/
│ ├── Theme.kt # MemoryWallTheme, AnalogColors
│ ├── CardStyleConfig.kt # LocalCardStyle, tiltFactor
│ └── Color.kt
├── detail/ # Photo detail from wall tap
├── archive/ # Hidden/archived photos
├── search/ # Search tab
├── collections/ # Collections tab + add-from-wall sheet
├── settings/ # Card Styles, General, Archive entry
├── permission/ # PhotoPermissions, PermissionScreen
├── splash/ # SplashScreen
└── onboarding/ # OnboardingScreen
Room tables collection, collection_photo, tag, and photo_tag exist in the schema for clean
migrations. See intro.md Phases 3–6 for planned work.
Requires JDK 17 and the Android SDK (compileSdk 35).
- Open the project in Android Studio (Ladybug or newer). Gradle sync generates the wrapper.
- Ensure
local.propertiespoints at your SDK:sdk.dir=/Users/<you>/Library/Android/sdk - Run the
appconfiguration on an emulator or device with photos.
./gradlew assembleDebug # build the APK
./gradlew test # run unit testsFrom app/build.gradle.kts:
minSdk 26,targetSdk 35applicationId "com.memorywall"- Kotlin + Compose, KSP, Hilt
| Library | Version | Use |
|---|---|---|
| Jetpack Compose + Material3 | — | UI |
| Navigation Compose | — | Routing |
| Room | 2.6.1 | memory_wall.db |
| Hilt | 2.52 | DI |
| Coil | 2.7.0 | Image loading |
| DataStore | — | Settings preferences |
| Accompanist Permissions | — | Runtime photo access |
| ExifInterface | — | EXIF in detail view |
Versions are managed in gradle/libs.versions.toml.
backup_rules.xml and
data_extraction_rules.xml include
memory_wall.db for device backup / transfer.
Unit tests cover the pure, Android-free logic that defines wall appearance and layout:
| Test | File | What it verifies |
|---|---|---|
| CardStyleTest | CardStyleTest.kt |
Per-photo rotation and tape are deterministic and within expected range (no Random) |
| WallGroupingTest | WallGroupingTest.kt |
Month keys (YYYY-MM) and header sequencing are correct |
Run with:
./gradlew testFuture phases — collections depth, tags, notes, search filters, organization, sharing — are
documented in intro.md.
Not yet on the wall (planned, not shipped):
- Drag animation on cards
- Drag photos into collections from the wall
- Import and camera capture via the center "+" button
Scaffolded Room tables for collections and tags are already in the schema so future migrations stay clean.