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.
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.
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.
app/The Android application module: all code, resources and the manifest.
app/build.gradle.ktsHow 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.xmlPermissions, 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.tomlThe one place every library and plugin version is written.
build.gradle.kts, settings.gradle.kts, gradle.properties, gradlewThe 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, LICENSEThe front page, how to contribute and what a change has to keep, how to report a vulnerability, and the MIT licence.
signing.properties, local.propertiesNot in the repository, on purpose: your signing key settings and your Android SDK path. Both are git-ignored.
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-inSession, theIndexConnection(address and password of the index),AccountLimits(with the photos-per-month arithmetic),SyncScheduleandSyncReport.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:cloneCompleteand 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.AppPrefscalls it; nothing else should store a secret any other way.PhotoCache.kt— the phone's own copy of the index, plus the outbox. AtVERSION 10, it holds thedocstable (id, size, indexed at, taken at, tags, persons, meaning, printed text, city, country, the whole document as JSON, modified), theactionsqueue (ACTION_INDEX,ACTION_WORDS,ACTION_DELETE), the word counts the suggestions are built from (wordCounts,wordCountsOf), and thephotos,edits,skipped,word_retries,placesandstampstables. 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 asfaces_json.Words.kt— the rule that decides two spellings are the same tag or the same person (fold,distinctWords).EditRepositoryandBulkTagSheetboth 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 inSearchRepositorygo 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 inAppPrefs.facetsJsonuntil 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,imageClipandimageIndex. 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()andforEachSizePage()are still here but are no longer part of an ordinary sync.UpdateCheck.ktandSelfUpdate.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 holdsphotoId(), 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 byMEANING_MAX_CHARS = 2000), and computesfileMd5, 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 throughSearchCachefirst, and anything the app writes empties that cache;browseFacets()is asked once and then held inAppPrefs.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, inAppViewModel.localWords()andcachedWordCounts()overPhotoCache.EditRepository.kt— the heart of 2.5.saveLocalwrites 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()andreadIndexIntoCache()do the one-time read of the index into the phone's copy, overCLONE_FIELDSand guarded byprefs.cloneComplete, which only counts while the copy has the shape the current clone format asks for.sendWords()drains the queue;storeDockeeps the copy in step with what the server says it wrote.
ui/ — the screens
AppViewModel.kt— the brain of the interface: oneUiStatevalue 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 fromUiState.screen.screens/— the screens: sign-in, welcome, permissions, folders and setup inOnboardingScreens.kt;SearchScreen.ktwith 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 byPhotoCache.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.
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()andreadIndexIntoCache()overSolrClient.forEachDoc(), asking only forCLONE_FIELDS. Only a read that reached the end setsprefs.cloneComplete; a read that stopped part way is done again next time, and raising the clone format inAppPrefsmakes every phone read its copy again. - After that the index is never walked again. Every write keeps the copy in step instead:
storeDocafter a write the server confirmed,updateDocSize,removeDocswhen photos go,clearDocswhen the index is rebuilt. If you add a write path, it keeps the copy in step or the copy is wrong. - The
actionstable is the app's outbox.ACTION_INDEXmeans a photo has to be read and written whole,ACTION_WORDSmeans only the words changed,ACTION_DELETEmeans 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()andphotoCache.docsBetween(). - The tag and name suggestions in the tag fields, counted by
AppViewModel.localWords()overphotoCache.wordCounts(). - The “already on these photos” counts under the tagging sheet for many photos.
- A sync that finds nothing to do.
Sign-in
SignInScreenbutton →AppViewModel.beginSignIn()→AuthFlow.start()saves the verifier and state inAppPrefsand opens the browser.- Android opens
AuthCallbackActivity, which forwards the address toMainActivity. AppViewModel.completeSignIn()checks the state and callsOpensolrApi.exchangeCode().- The session and plan limits go into
AppPrefs; the welcome screen shows the limits.
Sync
SyncScheduler.runNow()(or the schedule) startsSyncWorker, which runsSyncEngine.run().IndexManager.ensure()makes sure the index exists, usingOpensolrApi.MediaScanner.scan()lists the photos on the phone, and the list is compared againstPhotoCache(docSizes,docFileHash), entirely on the phone. The index is not listed. What differs goes into theactionsqueue.- The picture path, for anything that has to be read:
PhotoReader.copyForIngest()plus the owner's edits fromPhotoCache, thenOpensolrApi.photosIngest(), five photos per call (READ_BATCH = 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. - Either way,
EditRepository.storeDoc()writes back into the phone's copy what the server says it wrote. - If the plan has no vector search, or the month's AI allowance is spent, the
aiAvailablegate inSyncEngine.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. - The
SyncReportis saved;SyncScheduler.status()lets the screens redraw.
Browse and search
- With nothing typed and no filter on:
AppViewModelbuilds the Year > Month > Day skeleton fromphotoCache.takenTimes()and fills each day fromphotoCache.docsBetween(). No request is made. Today, Yesterday and the last few days sit above the years. - With text or filters:
AppViewModel.search()→SearchRepository.search(), which sends a search by meaning throughOpensolrApi.photosSelect()and everything else throughSolrClient.select(), throughSearchCache. - The results and facets go into
UiState;SearchScreendraws them; a tap callsActions.openPhoto().
Editing
EditSheetorBulkTagSheet→AppViewModel→EditRepository.saveLocal().saveLocalwrites theeditsrow, updates the phone's copy of the document, and puts anACTION_WORDSrow in the queue. The edit is finished here; the screen redraws at once.- A sync starts straight after and carries the queue up, fifty photos per call, no pictures.
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.
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.
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.
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.
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.
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.
AppViewModel.localWords() and cachedWordCounts() over PhotoCache.wordCounts() and wordCountsOf(), with the spelling rule in Words.kt — not SearchRepository, which is not asked at all.
The lex and vec parameters in SearchRepository.
A new value in Screen, a composable in ui/screens/, a branch in AppRoot.kt, and the actions in AppViewModel.
A property in AppPrefs, its default in UiState, and the control on the screen.
A function in OpensolrApi, any new refusal in Errors.kt, and a row in docs/how-it-works.md.
The catch blocks at the end of SyncEngine.run(), and the status wording in SyncScreen.kt.
A function in Notifier.kt.
ui/theme/Theme.kt.
gradle/libs.versions.toml, then build.
versionCode and versionName in app/build.gradle.kts, a signed release build, and a GitHub release with the APK. See building.
- Read building from source to get a debug build running on your phone.
- Read contributing for the conventions and the security rules every change keeps.
- Ask for contributor access at support@opensolr.com.