A commercial-grade iOS photo-library cleaner — finds duplicates, similar shots, screenshots, large videos (free) plus blurry photos, selfies, video compression and duplicate-contact merging (Premium), monetised with auto-renewable subscriptions.
- Swift, UIKit in code (no Storyboards except
LaunchScreen), iOS 16+ - MVVM + Coordinators, clean Presentation / Domain / Data / Services split
- async/await concurrency,
@MainActorUI - On-device Vision analysis (feature prints, Laplacian blur, face/selfie)
- Apphud + StoreKit, Firebase (Analytics / Crashlytics / Remote Config), AppsFlyer — all behind protocols, so the app builds & runs with zero keys
Out of the box the project compiles and runs against mock providers (no SPM packages, no keys). Adding the real SDKs + keys upgrades it to production without touching feature code.
| Tool | Version |
|---|---|
| Xcode | 16+ (built & tested on Xcode 26) |
| iOS deployment target | 16.0 |
| XcodeGen | brew install xcodegen |
brew install xcodegen
cd CleanMil
xcodegen generate # creates CleanMil.xcodeproj from project.yml
open CleanMil.xcodeproj
# select the CleanMil scheme + a simulator → RunRun the tests:
xcodebuild test -project CleanMil.xcodeproj -scheme CleanMil \
-destination 'platform=iOS Simulator,name=iPhone 16'Because no SDKs are linked, you get:
MockPurchasesProvider, ConsoleAnalytics, NoopCrashReporter,
NoopAttributionProvider, InMemoryRemoteConfigProvider. The full funnel
(onboarding → scan → review/swipe → paywall → "purchase" → premium unlock) works.
CleanMil/
├─ project.yml # XcodeGen spec (regenerate the .xcodeproj)
├─ codemagic.yaml # CI: build+test, and TestFlight workflows
├─ Config/ # xcconfig + Secrets (keys never committed)
│ ├─ Shared/Debug/Release.xcconfig
│ └─ Secrets.example.xcconfig → copy to Secrets.xcconfig
├─ CleanMil/
│ ├─ App/ # AppDelegate, SceneDelegate
│ ├─ Application/ # Coordinator protocol, AppCoordinator
│ ├─ DI/ # AppContainer (composition root), AppSecrets, Firebase bootstrap
│ ├─ Domain/ # framework-free Models, Protocols, UseCases
│ ├─ Data/ # Photos, Vision dedup, blur/selfie, video, contacts
│ ├─ Services/ # quota, subscription, analytics, remote config, crash, attribution
│ ├─ Presentation/ # Onboarding, Home, Review (+Swipe), Paywall, Settings, Common
│ └─ Resources/ # Info.plist, Assets, en/ru.lproj, LaunchScreen
└─ CleanMilTests/ # quota limiter, dedup clustering, subscription, remote-config mapping
- Domain imports only
Foundation/CoreGraphics— no UIKit/Photos/Vision/SDKs. - Data implements Domain protocols using Apple frameworks.
- Services implement Domain protocols using third-party SDKs (behind
#if canImport), with mock fallbacks. - Presentation depends on Domain protocols + the small service facades.
- AppContainer wires everything and vends view models.
Every adapter is wrapped in #if canImport(<Module>), so just adding the package
makes the real implementation light up; remove it and you fall back to mocks.
In project.yml, uncomment the packages: block and the matching dependencies:
entries (already stubbed), or in Xcode use File ▸ Add Package Dependencies:
| SDK | Package URL | Products to add |
|---|---|---|
| Apphud | https://github.com/apphud/ApphudSDK |
ApphudSDK |
| Firebase | https://github.com/firebase/firebase-ios-sdk |
FirebaseAnalytics, FirebaseCrashlytics, FirebaseRemoteConfig |
| AppsFlyer | https://github.com/AppsFlyerSDK/AppsFlyerFramework |
AppsFlyerLib |
Then xcodegen generate again (if you edited project.yml).
The Apphud adapter targets Apphud 3.5+; spots that vary across SDK versions are flagged with
// Apphud-API:.
cp Config/Secrets.example.xcconfig Config/Secrets.xcconfig # git-ignoredFill in:
APPHUD_API_KEY = app_xxxxxxxxxxxxxxxxxxxx # Apphud ▸ App Settings ▸ API Key
APPSFLYER_DEV_KEY = xxxxxxxxxxxxxxxxxxxxxx # AppsFlyer ▸ App Settings ▸ Dev Key
APPSFLYER_APP_ID = 1234567890 # numeric App Store app id
These flow into Info.plist via $(KEY) and are read by AppSecrets. Empty/placeholder
values are ignored, so partial configuration still runs.
Drop GoogleService-Info.plist into CleanMil/Resources/ (git-ignored).
FirebaseConfigurator calls FirebaseApp.configure() only when the plist and
the SDK are present — otherwise Firebase stays off and analytics go to the console.
Enable Crashlytics in the Firebase console; add a Run-Script build phase that uploads dSYMs if you want symbolicated crashes (see Firebase docs).
- Apps ▸ your app ▸ Subscriptions → create a Subscription Group (e.g.
CleanMil Premium). - Add two auto-renewable products and use these IDs (or change them in
MockPurchasesProvider/ Apphud):com.cleanmil.weekly— Weekly, with a 3-day free trial (Introductory Offer).com.cleanmil.yearly— Yearly.
- Add localized display names, prices, and a review screenshot.
- Fill the App Privacy section and add your Terms / Privacy URLs
(also set
CMTermsURL/CMPrivacyURLinInfo.plist). - In Apphud: connect your App Store Connect API key, import the products, and
build a Paywall/Placement containing them.
availableProducts()reads from Apphud Placements; pricing comes from StoreKit. - Test purchases with a Sandbox Apple ID.
Seed these keys in Firebase Remote Config (defaults are seeded in-app and live in
RemoteConfigValues.defaults). Mapping/validation is in RemoteConfigMapper
(unit-tested):
| Key | Type | Default | Purpose |
|---|---|---|---|
paywall_variant |
string | control |
control / trialFirst / valueProps |
trial_days |
int | 3 |
Trial length shown on the paywall |
free_daily_delete_limit |
int | 50 |
Free-tier deletions/day (0 = unlimited) |
hard_paywall |
bool | false |
Force paywall after onboarding (delayed close) |
large_video_threshold_mb |
int | 50 |
"Large video" cutoff |
duplicate_distance_threshold |
double | 0.10 |
Vision distance for duplicates |
similar_distance_threshold |
double | 0.35 |
Vision distance for similar |
Emitted via AnalyticsTracking (Firebase + console). Names are GA4 snake_case:
onboarding_completed, photo_permission, scan_started,
scan_finished (findings, potential bytes), paywall_shown (variant, source),
paywall_purchase_tapped, subscription_activated, purchase_failed,
restore_completed, cleanup_done (category, count, freed_bytes), quota_reached.
On subscription_activated the price/revenue is also sent to AppsFlyer
(af_purchase); the AppsFlyer UID is forwarded to Apphud for revenue attribution.
codemagic.yaml defines two workflows:
ios-build-test— every push/PR: installs XcodeGen, generates the project, resolves SPM, runs unit tests on a simulator (no signing). Caches SPM.ios-testflight— onv*tags: code-signs, bumps build number, builds the.ipa, and uploads to TestFlight.
Fill the placeholders marked « … »: the App Store Connect API key integration
name, bundle id, numeric app id, and notification email. Connect the repo in
Codemagic and add the App Store Connect integration under Team ▸ Integrations.
CleanMilTests covers the pure, high-value logic:
DeletionQuotaManagerTests— daily limit, counting, midnight rollover (injectedDateProvider), unlimited kill-switch.SimilarityClustererTests— single-linkage clustering, transitivity,canComparepre-filter, inclusive threshold.SubscriptionServiceTests— purchase activates + fires analytics/attribution,statusUpdates()multicast stream, restore.RemoteConfigMapperTests— typed mapping, defaults fallback, clamping, string-encoded values.
- All photo/video analysis runs on device; nothing is uploaded.
fileSizeis read fromPHAssetResourcevia KVC (App-Store-safe), with a pixel/duration-based fallback.- Deletions go through
PHPhotoLibrary.performChanges(system confirmation); user-cancellation is handled silently. - Dark theme is forced app-wide; SF Symbols throughout; RU/EN localized with a runtime language switch in Settings.
Proprietary — © CleanMil. Replace with your license of choice before shipping.