Project structure

What every folder is, how the pieces talk to each other, and where to go to change anything.

This page is for a developer who has just cloned the repository and wants to know what everything is, how the pieces talk to each other, and where to go to change something. It stays high level: after reading it you should be able to open the right file for any change.

Want to work on it with us?

Opensolr Photos is open source and contributions are welcome. To become a contributor to this or any other Opensolr open source project, write to support@opensolr.com and tell us what you would like to work on.

01 · The big picture in one minute

Opensolr Photos is a single Android app written in Kotlin, with its screens built in Jetpack Compose. There is no server of its own: everything it needs on the server side is an Opensolr service it calls over HTTPS (see every call it makes). Inside the app there are four flows, and almost every file belongs to one of them:

Sign-in

Open the browser, come back with a code, swap it for the account key. Lives in auth/, finishes in ui/AppViewModel.kt.

Sync

Find photos, compare them with the phone's own copy of the index, read the new ones, write them. The index itself is never listed. A sync with nothing to do makes no request at all. Lives in sync/, using media/, net/, index/ and data/.

Search and browse

Browsing by year, month and day is answered from the phone's copy and touches no network. Only typed text or filters become a Solr request. Lives in search/, ui/AppViewModel.kt and ui/screens/SearchScreen.kt.

Local-first editing

Tags, people and wording are saved on the phone and are finished there; a queue carries them up on the next sync. Lives in search/EditRepository.kt and data/PhotoCache.kt.

02 · The repository, top to bottom
app/

The Android application module: all code, resources and the manifest.

app/build.gradle.kts

How the app is built: package name, minimum and target Android versions, version number, dependencies, release signing, and the task that zips the Solr configuration into the APK.

app/src/main/AndroidManifest.xml

Permissions, the two activities (the app, and the sign-in callback with its App Link), and the foreground service type for sync.

app/src/main/java/com/opensolr/photos/

All Kotlin code, one folder per responsibility (section 03).

app/src/main/res/

Resources: strings, colours, launcher and notification icons, the bundled Space Grotesk font, and two XML files that switch off cleartext traffic and backups.

solr/conf/

The configuration uploaded to every phone's index: schema.xml, solrconfig.xml, stop words, synonyms, protected words.

docs/

The Markdown documentation shown on GitHub, and docs/images/ with every diagram as an SVG file.

gradle/libs.versions.toml

The one place every library and plugin version is written.

build.gradle.kts, settings.gradle.kts, gradle.properties, gradlew

The Gradle project around the app module, and the wrapper that downloads the right Gradle version.

third_party/

Licences of bundled third-party material (the font).

fastlane/

The store listing kept as files: titles, descriptions, change notes and screenshots.

README.md, CONTRIBUTING.md, SECURITY.md, LICENSE

The front page, how to contribute and what a change has to keep, how to report a vulnerability, and the MIT licence.

signing.properties, local.properties

Not in the repository, on purpose: your signing key settings and your Android SDK path. Both are git-ignored.

03 · The Kotlin packages

Everything below lives under app/src/main/java/com/opensolr/photos/. The packages are listed from the bottom of the stack (things that know nothing about screens) to the top (the screens).

data/ — what the app remembers

  • Models.kt — the plain data classes the rest of the app passes around: the signed-in Session, the IndexConnection (address and password of the index), AccountLimits (with the photos-per-month arithmetic), SyncSchedule and SyncReport.
  • AppPrefs.kt — every setting and saved value, in one private preferences file. If you need to remember something new between runs, add a property here. What 2.5 added lives here too: cloneComplete and the clone format (the phone's copy of the index and its shape), facetsJson (the filter lists, kept until a sync writes something), and the fold state of the grid headings, the filter groups and the Stats sections.
  • SecureStore.kt — encrypts and decrypts the two secrets with a Keystore key. AppPrefs calls it; nothing else should store a secret any other way.
  • PhotoCache.kt — the phone's own copy of the index, plus the outbox. At VERSION 10, it holds the docs table (id, size, indexed at, taken at, tags, persons, meaning, printed text, city, country, the whole document as JSON, modified), the actions queue (ACTION_INDEX, ACTION_WORDS, ACTION_DELETE), the word counts the suggestions are built from (wordCounts, wordCountsOf), and the photos, edits, skipped, word_retries, places and stamps tables. Section 04 explains how the copy is filled and kept in step.
  • FaceStore.kt — the faces of every photo read: frames, fingerprints, the names the owner gave and the faces refused for a person; they travel to your index as faces_json.
  • Words.kt — the rule that decides two spellings are the same tag or the same person (fold, distinctWords). EditRepository and BulkTagSheet both use it, so a change here changes both.
  • SearchCache.kt — a SQLite table of answers the index already gave, keyed by the request itself and kept for as long as the owner chose. Only the reads in SearchRepository go through it; writes and the schema checks never do, and anything the app writes empties it at once. The filter lists are the one thing not kept here: they are asked for once and held in AppPrefs.facetsJson until a sync actually writes something.

net/ — talking to Opensolr

  • Http.kt — the one shared HTTP client, with its timeouts and no redirects.
  • OpensolrApi.kt — one function per Opensolr call: token exchange, index list, create, config upload, connection details, account summary, photosIngest (photos_ingest), photosWords (photos_words, new in 2.5), photosSelect (photos_select), vectorRegions, nearbyPlaces, imageClip and imageIndex. It also turns the platform's refusals into typed errors.
  • SolrClient.kt — talks straight to the phone's index: search, forEachDoc(fields, ...) (what reads the index once into the phone's copy), the duplicate facets, add, delete, empty, commit, check the schema. allIds() and forEachSizePage() are still here but are no longer part of an ordinary sync.
  • UpdateCheck.kt and SelfUpdate.kt — in a copy installed from GitHub, ask GitHub for the latest release and install it through the system installer; a copy installed by Google Play or AppGallery is left to that store.
  • Errors.kt — the exceptions, named after what the app has to do about them (sign in again, quota used up, plan limit, rate limited, photo rejected...).

media/ — photos on the phone

  • MediaScanner.kt — lists folders, scans the chosen ones, and holds photoId(), the one place a photo's id is computed: the md5 of its path inside the phone's storage.
  • FaceEngine.kt — finds the faces in a photo and fingerprints them, on the phone (YuNet and SFace on LiteRT).
  • PhotoReader.kt — reads EXIF metadata and makes the upright 1024 px copy sent to be read. It never writes to the file; it reads what other apps, or versions of this app before 3.x, left in it (tagsIn, xmpWordsIn, faceAreasIn; wording capped by MEANING_MAX_CHARS = 2000), and computes fileMd5, the checksum the same-file test in 2.5 depends on.

index/ — the phone's index

  • IndexManager.kt — the index name, finding it, offering the indexes of other phones for re-use, creating it in the region the platform flags as nearest, uploading the configuration, and re-reading its password. The rule “only create when certainly missing” lives here.

auth/ — signing in

  • AuthFlow.kt — builds the PKCE request and opens the browser.
  • AuthCallbackActivity.kt — a tiny invisible screen Android opens at the end of the sign-in; it only forwards the address to the main screen.

sync/ — keeping the index in step

  • SyncEngine.kt — the sync algorithm itself, from “is the index there” to the final commit, including every stop condition. This is the heart of the app.
  • SyncWorker.kt — runs the engine as a background job with a progress notification, and makes sure only one runs at a time.
  • FaceWorker.kt — names the newest faces from the people known so far after each sync, and, on request, reads the faces of every photo once, only while charging.
  • SyncScheduler.kt — starts a sync now, sets the daily, weekly or monthly schedule, and exposes the live status the screens show.
  • PlanWatch.kt — works out what the plan's limits mean right now and posts one notification per situation, not one per photo.
  • Notifier.kt — every notification the app posts.

search/ — finding photos

  • FaceMatcher.kt — the people learned from the faces the owner named, the sure matches named on their own, the candidates offered under Is this X?, and the Same faces stop of similar photos. Everything is compared on the phone.
  • SearchRepository.kt — builds the Solr request from the text and the filters (words, meaning, facets) and parses the answer into results; also the duplicate groups. The reads it does make pass through SearchCache first, and anything the app writes empties that cache; browseFacets() is asked once and then held in AppPrefs.facetsJson. It is not asked at all for plain browsing, for tag and name suggestions, or for the counts under the tagging sheet: those are worked out on the phone, in AppViewModel.localWords() and cachedWordCounts() over PhotoCache.
  • EditRepository.kt — the heart of 2.5. saveLocal writes an edit to the phone and is finished there; the queue is carried up 50 photos per call with no picture attached (WORDS_BATCH = 50, photos_words). cloneMissing() and readIndexIntoCache() do the one-time read of the index into the phone's copy, over CLONE_FIELDS and guarded by prefs.cloneComplete, which only counts while the copy has the shape the current clone format asks for. sendWords() drains the queue; storeDoc keeps the copy in step with what the server says it wrote.

ui/ — the screens

  • AppViewModel.kt — the brain of the interface: one UiState value holding everything the screens draw, and one function per thing the user can do (sign in, save folders, search, force a re-sync, sign out...).
  • AppRoot.kt — picks which screen to draw from UiState.screen.
  • screens/ — the screens: sign-in, welcome, permissions, folders and setup in OnboardingScreens.kt; SearchScreen.kt with the header, the filter and details sheets, the duplicates slider, the Year > Month > Day grouping and its fold state, long-press selection and the group ticks; BulkTagSheet.kt, the tagging sheet for many photos at once (People, then My tags (Albums), each with an Add or Replace switch, and the counts of what the ticked photos already carry); EditSheet.kt; StatsScreen.kt (the charts and tables, counted by PhotoCache.libraryStats()); MapScreen.kt; SyncScreen.kt; AccountScreen.kt.
  • Components.kt — the shared building blocks: buttons, notices, labelled rows, usage bars, headers.
  • Actions.kt — things handed to other apps (open a photo, a map, a web page) and the date and number formats.
  • Haptics.kt — the one place the short vibrations are made, so a long press and a tick feel the same everywhere.
  • OutsideTap.kt — the helper that closes an open sheet or menu when the tap lands outside it.
  • map/PhotoClusterOverlay.kt — the groups of photos drawn on the map and what a tap on one does.
  • theme/Theme.kt — colours, typography and shapes, light and dark.

At the top

  • MainActivity.kt — the one real screen of the app: it hosts the Compose UI and passes sign-in callbacks and notification taps to the view model.
  • PhotosApp.kt — runs once when the app starts and creates the notification channels.
04 · The phone's copy of the index, and the queue

This is the one idea a contributor has to hold before changing anything else in 2.5. The phone keeps a copy of every document its index holds, in the docs table of PhotoCache. The copy carries every field except the search vector and the duplicate keys: id, path, file name, size, dates, camera, EXIF, place, the words the photo was read into, the printed text, the people, the owner's tags and the md5 of the file.

  • It is read from the index exactly once, at install or reinstall, by EditRepository.cloneMissing() and readIndexIntoCache() over SolrClient.forEachDoc(), asking only for CLONE_FIELDS. Only a read that reached the end sets prefs.cloneComplete; a read that stopped part way is done again next time, and raising the clone format in AppPrefs makes every phone read its copy again.
  • After that the index is never walked again. Every write keeps the copy in step instead: storeDoc after a write the server confirmed, updateDocSize, removeDocs when photos go, clearDocs when the index is rebuilt. If you add a write path, it keeps the copy in step or the copy is wrong.
  • The actions table is the app's outbox. ACTION_INDEX means a photo has to be read and written whole, ACTION_WORDS means only the words changed, ACTION_DELETE means a photo is gone. The sync drains the queue; nothing else sends anything.

Because of the copy, these are answered with no request at all, and a contributor should not put a Solr call back into any of them:

  • Browsing with nothing typed and no filter on: the years, the months, the days, their counts and the photos inside them, from photoCache.takenTimes() and photoCache.docsBetween().
  • The tag and name suggestions in the tag fields, counted by AppViewModel.localWords() over photoCache.wordCounts().
  • The “already on these photos” counts under the tagging sheet for many photos.
  • A sync that finds nothing to do.
05 · How the four flows move through the code

Sign-in

  1. SignInScreen button → AppViewModel.beginSignIn() → AuthFlow.start() saves the verifier and state in AppPrefs and opens the browser.
  2. Android opens AuthCallbackActivity, which forwards the address to MainActivity.
  3. AppViewModel.completeSignIn() checks the state and calls OpensolrApi.exchangeCode().
  4. The session and plan limits go into AppPrefs; the welcome screen shows the limits.

Sync

  1. SyncScheduler.runNow() (or the schedule) starts SyncWorker, which runs SyncEngine.run().
  2. IndexManager.ensure() makes sure the index exists, using OpensolrApi.
  3. MediaScanner.scan() lists the photos on the phone, and the list is compared against PhotoCache (docSizes, docFileHash), entirely on the phone. The index is not listed. What differs goes into the actions queue.
  4. The picture path, for anything that has to be read: PhotoReader.copyForIngest() plus the owner's edits from PhotoCache, then OpensolrApi.photosIngest(), five photos per call (READ_BATCH = 5).
  5. The words-only path, for anything where only the words changed: OpensolrApi.photosWords(), fifty photos per call (WORDS_BATCH = 50), with no picture attached.
  6. Either way, EditRepository.storeDoc() writes back into the phone's copy what the server says it wrote.
  7. If the plan has no vector search, or the month's AI allowance is spent, the aiAvailable gate in SyncEngine.run() sends nothing to be read: the document is built from the date, the camera, the place, the file name and the owner's own words, and search is lexical.
  8. The SyncReport is saved; SyncScheduler.status() lets the screens redraw.

Browse and search

  1. With nothing typed and no filter on: AppViewModel builds the Year > Month > Day skeleton from photoCache.takenTimes() and fills each day from photoCache.docsBetween(). No request is made. Today, Yesterday and the last few days sit above the years.
  2. With text or filters: AppViewModel.search() → SearchRepository.search(), which sends a search by meaning through OpensolrApi.photosSelect() and everything else through SolrClient.select(), through SearchCache.
  3. The results and facets go into UiState; SearchScreen draws them; a tap calls Actions.openPhoto().

Editing

  1. EditSheet or BulkTagSheet → AppViewModel → EditRepository.saveLocal().
  2. saveLocal writes the edits row, updates the phone's copy of the document, and puts an ACTION_WORDS row in the queue. The edit is finished here; the screen redraws at once.
  3. A sync starts straight after and carries the queue up, fifty photos per call, no pictures.
06 · Where to go to change something
Store a new piece of photo information

solr/conf/schema.xml (the field) and the server side of photos_ingest, which is what builds the document; on the phone, usually PhotoReader (read it off the file) and the IngestItem that carries it up in SyncEngine. Document it in docs/. Existing indexes pick up the new schema only if IndexManager detects it is missing, so change its schema check too. A field that is not also added to PhotoCache.Doc, the docs table, the VERSION bump and onUpgrade, CLONE_FIELDS and EditRepository.storeDoc never reaches the phone's copy, and nothing that reads from the copy will ever see it.

Change the phone's copy of the index

A column: DOCS_TABLE, the VERSION bump and onUpgrade in PhotoCache.kt, then CLONE_FIELDS and storeDoc in EditRepository.kt. Old installs only get the column through onUpgrade, so write that path first; and if the copies already on phones cannot fill it by themselves, raise the clone format in AppPrefs so every one of them is read again.

Add a filter

SearchFilters and SearchRepository (the fq and the facet), then the filter sheet in SearchScreen.kt. Each filter group there also has its fold state, its memory between visits and the badge counting how many of its own filters are on, and the lists are held in AppPrefs.facetsJson until a sync writes something, so a new list has to travel with them.

Change how browsing groups photos

The skeleton builder in AppViewModel (Year > Month > Day, and the recent days kept above the years), then SearchScreen.kt for how the headings draw and how their fold state is remembered.

Change how photos are selected

SearchScreen.kt and the selection state in AppViewModel: a long press starts selection, it ends by itself when the last tick goes, and a tick on a group heading takes the whole year, month or day rather than what is loaded on screen. The text-search bands have no group tick because their boundary moves as more results arrive.

Change when a photo counts as unchanged

PhotoCache.docFileHash against PhotoReader.fileMd5. This is what stops a header-only rewrite from costing a re-read, and what keeps the printed text and the words already read out of a photo when a later pass cannot read it.

Change a suggestion list in a tag field

AppViewModel.localWords() and cachedWordCounts() over PhotoCache.wordCounts() and wordCountsOf(), with the spelling rule in Words.kt — not SearchRepository, which is not asked at all.

Change how search ranks

The lex and vec parameters in SearchRepository.

Add a screen

A new value in Screen, a composable in ui/screens/, a branch in AppRoot.kt, and the actions in AppViewModel.

Remember a new setting

A property in AppPrefs, its default in UiState, and the control on the screen.

Call a new Opensolr endpoint

A function in OpensolrApi, any new refusal in Errors.kt, and a row in docs/how-it-works.md.

Change how a sync reacts to an error

The catch blocks at the end of SyncEngine.run(), and the status wording in SyncScreen.kt.

Add a notification

A function in Notifier.kt.

Change colours or fonts

ui/theme/Theme.kt.

Upgrade a library

gradle/libs.versions.toml, then build.

Release a new version

versionCode and versionName in app/build.gradle.kts, a signed release build, and a GitHub release with the APK. See building.

07 · Before you start

Opensolr Photos is open source and MIT licensed. Questions about your Opensolr account, index or plan go to opensolr.com/contact; questions about the app itself belong on GitHub.

Opensolr Photos Documentation