Skip to content

Repository files navigation

memory wall

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.


Core principles

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.

What the Memory Wall is

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.

Navigation context

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
Loading

User interactions

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

What is not on the wall

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.


Visual design

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.

Polaroid cards

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)

Paper surface and theme

  • PaperSurface.kt — paper background
  • Theme.ktMemoryWallTheme, AnalogColors (ink, paper, tape palette)
  • Light / dark / system theme via themeMode setting

Sticky tape

StickyTape.kt draws tape strips on some cards. Placement is deterministic (TapeStyle: none, center, left, right). Can be disabled globally in Settings → Card Styles.

Deterministic tilt and tape

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.

Month separators

MonthSeparator.kt renders ribbon-style month headers between card groups. Keys use YYYY-MM for stable ordering; labels use locale-aware "MMMM yyyy" formatting.


Architecture

Pattern: MVVM + Repository. Hilt for DI, Navigation Compose for routing, Coil for images, Room for metadata, DataStore for settings.

Data flow

flowchart LR
    MediaStore --> MediaStorePhotoSource
    MediaStorePhotoSource -->|ContentObserver| PhotoRepository
    RoomPhotoMeta --> PhotoRepository
    PhotoRepository -->|Flow List WallCard| WallViewModel
    SettingsDataStore --> WallViewModel
    WallViewModel --> WallScreen
    WallViewModel -->|bulk actions| MetadataRepository
    MetadataRepository --> RoomPhotoMeta
Loading

Merge logic

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 pipeline

WallViewModel.kt combines four streams into WallUiState:

  1. photoRepository.observeCards() — all cards (includes archived/hidden in raw list)
  2. selectedIds — multi-select state
  3. filterWallFilter (all / favorites / pinned)
  4. settingsRepository.settings — grid columns, sort direction, confirm-delete

Processing steps:

  1. Visibility — drop archived and hidden cards
  2. Filter — apply favorites or pinned filter when active
  3. GroupingWallGrouping.group() flattens into WallListItem (month headers + cards)

Live updates

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.

ViewModel ownership

WallViewModel is scoped at MainScreen (not inside WallScreen) so:

  • Selection state persists across tab switches
  • MainScreen can swap the bottom bar between AppBottomBar and MultiSelectBar
  • Delete, share, and add-to-collection flows stay in one place

Data model

Defined in Photo.kt.

Photo

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+)

PhotoMeta

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)

WallCard

Join of Photo + PhotoMeta. The unit the wall grid renders.

Room storage

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,
    ...
)

Visibility on the wall

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

Month grouping

WallGrouping.kt flattens filtered cards into a list of WallListItem:

  • MonthHeader(key, label) — e.g. key 2026-06, label June 2026
  • Card(card) — the WallCard itself

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.


Multi-select and bulk actions

Entering selection

  1. Long-press a card → startSelection(id) — one card selected, selection mode active
  2. Bottom bar swaps to MultiSelectBar
  3. Top bar shows count and close button
  4. Filter chips hide during selection

Actions

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)

Delete flow

  1. User taps delete → optional confirmation dialog if confirmDelete is true
  2. PhotoRepository.requestDelete(uris)MediaStorePhotoSource.requestDelete()
  3. API 30+: MediaStore.createDeleteRequestNeedsConsent → system UI via IntentSender
  4. Legacy: direct delete or RecoverableSecurityException consent
  5. On success: onDeleteConfirmed(ids)forgetMeta(ids) removes stale Room rows

Handled in MainScreen.kt with ActivityResultContracts.StartIntentSenderForResult.


Settings that affect the wall

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.


Permissions

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_STORAGE with maxSdkVersion="28" for legacy delete only
  • Modern deletes use MediaStore consent — no write permission declared for API 29+

Permission flows

  1. First launch — dedicated PermissionScreen after onboarding
  2. Wall recovery — if access is denied while on the wall, PermissionRecovery UI replaces the grid
  3. Re-grantLaunchedEffect(hasAccess) calls viewModel.refresh() when access returns

Integration with other screens

All screens below consume PhotoRepository.observeCards() — a single source of truth.

Photo detail

  • 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

Archive

  • Entry: Settings → Archive
  • Shows: hidden and archived cards
  • Restore: unhide / unarchive returns photos to the wall
  • Files: ArchiveScreen.kt

Search

  • 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

Collections

Center "+" action

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.


App shell and onboarding

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

Source file map

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

Scaffolded for future phases

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.


Build & run

Requires JDK 17 and the Android SDK (compileSdk 35).

Android Studio (recommended)

  1. Open the project in Android Studio (Ladybug or newer). Gradle sync generates the wrapper.
  2. Ensure local.properties points at your SDK: sdk.dir=/Users/<you>/Library/Android/sdk
  3. Run the app configuration on an emulator or device with photos.

Command line

./gradlew assembleDebug   # build the APK
./gradlew test            # run unit tests

Build config

From app/build.gradle.kts:

  • minSdk 26, targetSdk 35
  • applicationId "com.memorywall"
  • Kotlin + Compose, KSP, Hilt

Key dependencies

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

backup_rules.xml and data_extraction_rules.xml include memory_wall.db for device backup / transfer.


Tests

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 test

Roadmap

Future 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.

About

Calm, offline-inspired Android photo gallery — polaroid cards on a paper memory wall.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages