Skip to content

Commit f3c168c

Browse files
committed
feat: secure adult content access and refresh project identity
1 parent 0a2dfb9 commit f3c168c

22 files changed

Lines changed: 402 additions & 83 deletions

.github/workflows/contract-sync-android.yml

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ jobs:
1919
timeout-minutes: 10
2020
env:
2121
GH_TOKEN: ${{ secrets.ANDROID_CONTRACT_SYNC_TOKEN }}
22-
ANDROID_REPOSITORY: LamPPKK/Android.Smart.Movie
22+
ANDROID_REPOSITORY: LamPPKK/Smart-Movie-Android
2323
steps:
2424
- uses: actions/checkout@v7
2525

@@ -66,7 +66,7 @@ jobs:
6666
snapshot = {
6767
"schema_version": 1,
6868
"contract_version": canonical["contract_version"],
69-
"upstream_repository": "LamPPKK/SmartMovie",
69+
"upstream_repository": "LamPPKK/Smart-Movie-iOS",
7070
"upstream_commit": commit,
7171
"openapi_sha256": canonical["openapi_sha256"],
7272
"fixtures_sha256": canonical["fixtures_sha256"],
@@ -99,5 +99,5 @@ jobs:
9999
--base main \
100100
--head "$branch" \
101101
--title "chore: sync SmartMovie catalog contract" \
102-
--body "Automated snapshot of the canonical catalog contract from LamPPKK/SmartMovie@$GITHUB_SHA. Merge only after Android and desktop conformance CI passes."
102+
--body "Automated snapshot of the canonical catalog contract from LamPPKK/Smart-Movie-iOS@$GITHUB_SHA. Merge only after Android and desktop conformance CI passes."
103103
fi

.github/workflows/worker.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -139,8 +139,8 @@ jobs:
139139
- name: Require Android main to pin this contract
140140
working-directory: ${{ github.workspace }}
141141
env:
142-
ANDROID_CONTRACT_MANIFEST: https://raw.githubusercontent.com/LamPPKK/Android.Smart.Movie/main/catalog-contract/manifest.json
143-
ANDROID_RELEASE_MANIFEST: https://raw.githubusercontent.com/LamPPKK/Android.Smart.Movie/main/release/train.json
142+
ANDROID_CONTRACT_MANIFEST: https://raw.githubusercontent.com/LamPPKK/Smart-Movie-Android/main/catalog-contract/manifest.json
143+
ANDROID_RELEASE_MANIFEST: https://raw.githubusercontent.com/LamPPKK/Smart-Movie-Android/main/release/train.json
144144
run: |
145145
set -euo pipefail
146146
local_checksum="$(node -e 'const fs=require("node:fs"); console.log(JSON.parse(fs.readFileSync("backend/worker/contract/v2/manifest.json", "utf8")).openapi_sha256)')"

README.md

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
1-
# SmartMovie 3.0 — Apple apps and TMDb catalog backend
1+
# Smart Movie iOS 3.0 — Apple apps and TMDb catalog backend
22

3-
[![Swift](https://github.com/LamPPKK/SmartMovie/actions/workflows/swift.yml/badge.svg?branch=main)](https://github.com/LamPPKK/SmartMovie/actions/workflows/swift.yml)
4-
[![Xcode build and analyze](https://github.com/LamPPKK/SmartMovie/actions/workflows/xcode-build-analyze.yml/badge.svg?branch=main)](https://github.com/LamPPKK/SmartMovie/actions/workflows/xcode-build-analyze.yml)
5-
[![iOS smoke test](https://github.com/LamPPKK/SmartMovie/actions/workflows/ios.yml/badge.svg?branch=main)](https://github.com/LamPPKK/SmartMovie/actions/workflows/ios.yml)
6-
[![Catalog Worker](https://github.com/LamPPKK/SmartMovie/actions/workflows/worker.yml/badge.svg?branch=main)](https://github.com/LamPPKK/SmartMovie/actions/workflows/worker.yml)
3+
[![Swift](https://github.com/LamPPKK/Smart-Movie-iOS/actions/workflows/swift.yml/badge.svg?branch=main)](https://github.com/LamPPKK/Smart-Movie-iOS/actions/workflows/swift.yml)
4+
[![Xcode build and analyze](https://github.com/LamPPKK/Smart-Movie-iOS/actions/workflows/xcode-build-analyze.yml/badge.svg?branch=main)](https://github.com/LamPPKK/Smart-Movie-iOS/actions/workflows/xcode-build-analyze.yml)
5+
[![iOS smoke test](https://github.com/LamPPKK/Smart-Movie-iOS/actions/workflows/ios.yml/badge.svg?branch=main)](https://github.com/LamPPKK/Smart-Movie-iOS/actions/workflows/ios.yml)
6+
[![Catalog Worker](https://github.com/LamPPKK/Smart-Movie-iOS/actions/workflows/worker.yml/badge.svg?branch=main)](https://github.com/LamPPKK/Smart-Movie-iOS/actions/workflows/worker.yml)
77

88
SmartMovie is a cinematic TMDb catalog for the Apple ecosystem. It covers movies, television, people, collections, companies, networks, keywords, seasons, and episodes; adds region-aware availability and trailers; and can optionally synchronize library, ratings, recommendations, and mixed lists through a browser-approved TMDb account.
99

@@ -19,8 +19,8 @@ This repository contains the native SwiftUI apps for iPhone, iPad, Mac Catalyst,
1919

2020
| Repository | Owns | Mobile release role |
2121
| --- | --- | --- |
22-
| **[SmartMovie](https://github.com/LamPPKK/SmartMovie)** (this repository) | SwiftUI Apple apps, `SmartMovieKit`, Cloudflare Worker, OpenAPI 3.1 contract, canonical fixtures | App Store source of truth and backend owner |
23-
| **[Android.Smart.Movie](https://github.com/LamPPKK/Android.Smart.Movie)** | Native Android and Wear OS apps, Compose Multiplatform desktop/web app, versioned contract snapshot | Google Play source of truth |
22+
| **[Smart Movie iOS](https://github.com/LamPPKK/Smart-Movie-iOS)** (this repository) | SwiftUI Apple apps, `SmartMovieKit`, Cloudflare Worker, OpenAPI 3.1 contract, canonical fixtures | App Store source of truth and backend owner |
23+
| **[Smart Movie Android](https://github.com/LamPPKK/Smart-Movie-Android)** | Native Android and Wear OS apps, Compose Multiplatform desktop/web app, versioned contract snapshot | Google Play source of truth |
2424

2525
The two native mobile apps keep platform-specific UI and storage while sharing the additive `/v2` Worker contract, six locales, semantic release train, normalized errors, and deterministic decoder fixtures. `/v1` remains served for SmartMovie 2.0 compatibility. Account identity and content belong to TMDb; SmartMovie only brokers opaque sessions and maintains local-first caches/outboxes.
2626

@@ -161,8 +161,8 @@ sudo xcode-select --switch /Applications/Xcode.app/Contents/Developer
161161
Clone, generate, and open the project:
162162

163163
```sh
164-
git clone https://github.com/LamPPKK/SmartMovie.git
165-
cd SmartMovie
164+
git clone https://github.com/LamPPKK/Smart-Movie-iOS.git
165+
cd Smart-Movie-iOS
166166
xcodegen generate --spec SmartMovie/project.yml
167167
open SmartMovie/SmartMovie.xcodeproj
168168
```
@@ -202,7 +202,7 @@ cd ../..
202202
./scripts/verify-release.sh
203203
```
204204

205-
The current verified local baseline contains 57 Swift tests and 91 Worker tests. Coverage includes canonical `/v1` and `/v2` fixture decoding, configured capability/fixture equality, fail-closed browser/TV account rollout, malformed broker configuration and return-URI allowlists, cold-start callback deferral, stale completion invalidation, durable outbox isolation, capability-gated Advanced Discover and Profile provider regions with a fail-closed `/v1` fallback, complete Movie/TV Discover queries and regional provider configuration, External ID and Credit Detail source/path mapping, account recommendations, normalized/paginated custom mixed lists, restart-safe pending item snapshots, local adult filtering and in-flight request invalidation, metadata/item mutations, normalized person/title credit links, unknown and missing nullable fields, success/error schema validation, repeatable D1 migrations, encryption/callback/CSRF controls, durable idempotency, TMDb Changes pagination/backlog recovery, invalid cursor recovery, verified changing-page-count fallback, D1 parameter-bound chunking, monotonic revision and cache-bypass behavior, retries, cancellation, pagination, and data behavior without live personal credentials.
205+
The current verified local baseline contains 60 Swift tests and 91 Worker tests. Coverage includes canonical `/v1` and `/v2` fixture decoding, configured capability/fixture equality, fail-closed browser/TV account rollout, malformed broker configuration and return-URI allowlists, cold-start callback deferral, stale completion invalidation, durable outbox isolation, capability-gated Advanced Discover and Profile provider regions with a fail-closed `/v1` fallback, complete Movie/TV Discover queries and regional provider configuration, External ID and Credit Detail source/path mapping, account recommendations, normalized/paginated custom mixed lists, restart-safe pending item snapshots, explicit adult age confirmation, six-digit PIN validation, five-attempt lockout, local adult filtering and in-flight request invalidation, metadata/item mutations, normalized person/title credit links, unknown and missing nullable fields, success/error schema validation, repeatable D1 migrations, encryption/callback/CSRF controls, durable idempotency, TMDb Changes pagination/backlog recovery, invalid cursor recovery, verified changing-page-count fallback, D1 parameter-bound chunking, monotonic revision and cache-bypass behavior, retries, cancellation, pagination, and data behavior without live personal credentials.
206206

207207
CI independently builds and analyzes iOS, iPad/Catalyst, tvOS, native macOS, watchOS, and visionOS, then installs and launches the iOS app in Simulator. Read [Testing](docs/TESTING.md) for destination-specific commands and the manual device matrix.
208208

SmartMovieKit/Sources/SmartMovieKit/App/AccountSession.swift

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -167,7 +167,11 @@ public final class AdultContentController {
167167
public var includeAdult: Bool { isEnabled && isUnlocked && !isLocked }
168168
public var isLocked: Bool { lockUntil.map { $0 > now() } ?? false }
169169

170-
public func configure(pin: String, confirmation: String) -> Bool {
170+
public func configure(pin: String, confirmation: String, ageConfirmed: Bool) -> Bool {
171+
guard ageConfirmed else {
172+
errorMessage = String(localized: "Confirm that you are at least 18 years old.", bundle: .module)
173+
return false
174+
}
171175
guard pin == confirmation, pin.range(of: #"^[0-9]{6}$"#, options: .regularExpression) != nil else {
172176
errorMessage = String(localized: "Enter the same six-digit PIN twice.", bundle: .module)
173177
return false

SmartMovieKit/Sources/SmartMovieKit/App/FeatureModels.swift

Lines changed: 39 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -20,9 +20,7 @@ public final class ExploreViewModel {
2020
private var configurationTask: Task<Void, Never>?
2121
private var requestID = UUID()
2222
private var configurationRequestID = UUID()
23-
public init(catalog: any CatalogRepository) {
24-
self.catalog = catalog
25-
}
23+
public init(catalog: any CatalogRepository) { self.catalog = catalog }
2624

2725
public var providers: [WatchProviderOption] {
2826
configuration?.watchProviders?.values(for: mediaType) ?? []
@@ -264,10 +262,7 @@ public final class SearchViewModel {
264262
public var externalIDSource: ExternalIDSource = .imdb
265263
public private(set) var entities: [CatalogEntity] = []
266264
public var items: [TitleSummary] {
267-
entities.compactMap { entity in
268-
if case .title(let title) = entity { return title }
269-
return nil
270-
}
265+
entities.compactMap { if case .title(let title) = $0 { title } else { nil } }
271266
}
272267
public private(set) var isLoading = false
273268
public private(set) var errorMessage: String?
@@ -276,6 +271,8 @@ public final class SearchViewModel {
276271
private let catalog: any CatalogRepository
277272
private var page = 0
278273
private var searchTask: Task<Void, Never>?
274+
private var searchGeneration = 0
275+
private var includeAdult = false
279276

280277
public init(catalog: any CatalogRepository) {
281278
self.catalog = catalog
@@ -284,6 +281,7 @@ public final class SearchViewModel {
284281
public func setMode(_ mode: CatalogSearchMode) {
285282
guard self.mode != mode else { return }
286283
searchTask?.cancel()
284+
searchGeneration += 1
287285
self.mode = mode
288286
query = ""
289287
entities = []
@@ -297,6 +295,7 @@ public final class SearchViewModel {
297295
public func resetExternalIDResults() {
298296
guard mode == .externalID else { return }
299297
searchTask?.cancel()
298+
searchGeneration += 1
300299
entities = []
301300
errorMessage = nil
302301
isLoading = false
@@ -305,6 +304,8 @@ public final class SearchViewModel {
305304

306305
public func scheduleSearch(language: String, region: String? = nil, includeAdult: Bool = false) {
307306
searchTask?.cancel()
307+
searchGeneration += 1
308+
self.includeAdult = includeAdult
308309
guard mode == .catalog else { return }
309310
let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines)
310311
guard !trimmed.isEmpty else {
@@ -317,6 +318,7 @@ public final class SearchViewModel {
317318
let expectedQuery = trimmed
318319
let expectedScope = scope
319320
let expectedEntityScope = entityScope
321+
let expectedGeneration = searchGeneration
320322
searchTask = Task { [weak self, catalog] in
321323
do {
322324
try await Task.sleep(for: .milliseconds(350))
@@ -349,6 +351,8 @@ public final class SearchViewModel {
349351
)
350352
}
351353
guard !Task.isCancelled,
354+
searchGeneration == expectedGeneration,
355+
self.includeAdult == includeAdult,
352356
query.trimmingCharacters(in: .whitespacesAndNewlines) == expectedQuery,
353357
scope == expectedScope,
354358
entityScope == expectedEntityScope else { return }
@@ -365,8 +369,10 @@ public final class SearchViewModel {
365369
}
366370
}
367371

368-
public func findExternalID(language: String) {
372+
public func findExternalID(language: String, includeAdult: Bool = false) {
369373
searchTask?.cancel()
374+
searchGeneration += 1
375+
self.includeAdult = includeAdult
370376
let externalID = query.trimmingCharacters(in: .whitespacesAndNewlines)
371377
guard mode == .externalID, !externalID.isEmpty else {
372378
entities = []
@@ -376,6 +382,7 @@ public final class SearchViewModel {
376382
}
377383
hasSubmittedExternalID = true
378384
let expectedSource = externalIDSource
385+
let expectedGeneration = searchGeneration
379386
searchTask = Task { [weak self, catalog] in
380387
guard let self else { return }
381388
isLoading = true
@@ -389,10 +396,12 @@ public final class SearchViewModel {
389396
language: language
390397
)
391398
guard !Task.isCancelled,
399+
searchGeneration == expectedGeneration,
400+
self.includeAdult == includeAdult,
392401
mode == .externalID,
393402
query.trimmingCharacters(in: .whitespacesAndNewlines) == externalID,
394403
externalIDSource == expectedSource else { return }
395-
entities = result.results
404+
entities = result.results.filter { includeAdult || !$0.isAdultTitle }
396405
isLoading = false
397406
} catch is CancellationError {
398407
return
@@ -427,10 +436,10 @@ public final class SearchViewModel {
427436
let expectedScope = scope
428437
let expectedEntityScope = entityScope
429438
let nextPage = page + 1
439+
let expectedGeneration = searchGeneration
430440
isLoading = true
431-
Task { [weak self, catalog] in
441+
searchTask = Task { [weak self, catalog] in
432442
guard let self else { return }
433-
defer { isLoading = false }
434443
do {
435444
let result: PagedResult<CatalogEntity>
436445
if let catalogV2 = catalog as? any CatalogV2Repository {
@@ -457,17 +466,35 @@ public final class SearchViewModel {
457466
results: legacy.results.map(CatalogEntity.title)
458467
)
459468
}
460-
guard query.trimmingCharacters(in: .whitespacesAndNewlines) == expectedQuery,
469+
guard !Task.isCancelled,
470+
searchGeneration == expectedGeneration,
471+
self.includeAdult == includeAdult,
472+
query.trimmingCharacters(in: .whitespacesAndNewlines) == expectedQuery,
461473
scope == expectedScope,
462474
entityScope == expectedEntityScope else { return }
463475
entities.append(contentsOf: result.results.filter { incoming in
464476
!entities.contains(where: { $0.id == incoming.id })
465477
})
466478
page = result.page
467479
canLoadMore = result.page < result.totalPages
480+
isLoading = false
481+
} catch is CancellationError {
482+
return
468483
} catch {
484+
guard searchGeneration == expectedGeneration else { return }
485+
isLoading = false
469486
errorMessage = error.localizedDescription
470487
}
471488
}
472489
}
490+
491+
public func applyAdultVisibility(includeAdult: Bool) {
492+
self.includeAdult = includeAdult
493+
guard !includeAdult else { return }
494+
searchTask?.cancel()
495+
searchGeneration += 1
496+
entities.removeAll(where: \.isAdultTitle)
497+
isLoading = false
498+
canLoadMore = false
499+
}
473500
}
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
extension CatalogEntity {
2+
var isAdultTitle: Bool {
3+
if case .title(let title) = self { return title.isAdult }
4+
return false
5+
}
6+
}

SmartMovieKit/Sources/SmartMovieKit/Resources/en.lproj/Localizable.strings

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,3 +133,19 @@
133133
"Provider region" = "Provider region";
134134
"Watch providers" = "Watch providers";
135135
"TMDb account is temporarily unavailable." = "TMDb account is temporarily unavailable.";
136+
"Adult content" = "Adult content";
137+
"Off by default. Confirm you are at least 18 and create a six-digit PIN stored only on this device. Adult titles never appear on Watch/Wear remote or public previews." = "Off by default. Confirm you are at least 18 and create a six-digit PIN stored only on this device. Adult titles never appear on Watch/Wear remote or public previews.";
138+
"I confirm that I am at least 18 years old." = "I confirm that I am at least 18 years old.";
139+
"Confirm that you are at least 18 years old." = "Confirm that you are at least 18 years old.";
140+
"Six-digit PIN" = "Six-digit PIN";
141+
"Confirm PIN" = "Confirm PIN";
142+
"Enable adult content" = "Enable adult content";
143+
"Unlocked on this device" = "Unlocked on this device";
144+
"Lock" = "Lock";
145+
"Disable" = "Disable";
146+
"Enter PIN" = "Enter PIN";
147+
"Unlock" = "Unlock";
148+
"Enter the same six-digit PIN twice." = "Enter the same six-digit PIN twice.";
149+
"Incorrect PIN." = "Incorrect PIN.";
150+
"Too many attempts. Try again in five minutes." = "Too many attempts. Try again in five minutes.";
151+
"Source repositories" = "Source repositories";

SmartMovieKit/Sources/SmartMovieKit/Resources/ja.lproj/Localizable.strings

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,3 +133,19 @@
133133
"Provider region" = "配信地域";
134134
"Watch providers" = "視聴サービス";
135135
"TMDb account is temporarily unavailable." = "TMDbアカウントは一時的に利用できません。";
136+
"Adult content" = "成人向けコンテンツ";
137+
"Off by default. Confirm you are at least 18 and create a six-digit PIN stored only on this device. Adult titles never appear on Watch/Wear remote or public previews." = "初期設定はオフです。18歳以上であることを確認し、この端末だけに保存する6桁PINを作成してください。成人向け作品はWatch/Wearリモートや公開プレビューには表示されません。";
138+
"I confirm that I am at least 18 years old." = "18歳以上であることを確認します。";
139+
"Confirm that you are at least 18 years old." = "18歳以上であることを確認してください。";
140+
"Six-digit PIN" = "6桁PIN";
141+
"Confirm PIN" = "PINを確認";
142+
"Enable adult content" = "成人向けを有効化";
143+
"Unlocked on this device" = "この端末でロック解除済み";
144+
"Lock" = "ロック";
145+
"Disable" = "無効化";
146+
"Enter PIN" = "PINを入力";
147+
"Unlock" = "ロック解除";
148+
"Enter the same six-digit PIN twice." = "同じ6桁PINを2回入力してください。";
149+
"Incorrect PIN." = "PINが正しくありません。";
150+
"Too many attempts. Try again in five minutes." = "試行回数を超えました。5分後に再試行してください。";
151+
"Source repositories" = "ソースリポジトリ";

0 commit comments

Comments
 (0)