feat: integrate KordantSpamShieldExtension target and complete App Review compliance (Task 28)
- Add KordantSpamShieldExtension target to project.yml with proper app-extension type, bundle identifier, and deployment target - Create CallKit + App Group entitlements for SpamShield extension - Move SpamDirectoryService to Sources/Shared for cross-target access - Update app-review-checklist with 5 new technical items (total: 121) - Update rejection-risk-mitigation with extension build integration - Add SpamShield extension details to reviewer notes - Mark Task 24 (push deep links) and Task 28 as complete
This commit is contained in:
@@ -0,0 +1,12 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>com.apple.developer.callkit</key>
|
||||||
|
<true/>
|
||||||
|
<key>com.apple.security.application-groups</key>
|
||||||
|
<array>
|
||||||
|
<string>group.com.frenocorp.kordant</string>
|
||||||
|
</array>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
117
iOS/Sources/Shared/SpamDirectoryService.swift
Normal file
117
iOS/Sources/Shared/SpamDirectoryService.swift
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// A structure to hold identified numbers and their labels.
|
||||||
|
struct IdentifiedEntry: Codable {
|
||||||
|
let number: Int64
|
||||||
|
let label: String
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Service to manage spam and identification numbers in a shared App Group container.
|
||||||
|
final class SpamDirectoryService {
|
||||||
|
static let shared = SpamDirectoryService()
|
||||||
|
|
||||||
|
private let appGroupIdentifier = "group.com.frenocorp.kordant"
|
||||||
|
private let blockedNumbersFile = "blocked_numbers.bin"
|
||||||
|
private let identifiedEntriesFile = "identified_entries.json"
|
||||||
|
|
||||||
|
private let fileManager = FileManager.default
|
||||||
|
|
||||||
|
private var appGroupURL: URL? {
|
||||||
|
fileManager.containerURL(forSecurityApplicationGroupIdentifier: appGroupIdentifier)
|
||||||
|
}
|
||||||
|
|
||||||
|
private init() {}
|
||||||
|
|
||||||
|
// MARK: - Blocked Numbers
|
||||||
|
|
||||||
|
func addBlockedNumber(_ number: Int64) throws {
|
||||||
|
var blocked = try loadBlockedNumbers()
|
||||||
|
if !blocked.contains(number) {
|
||||||
|
blocked.append(number)
|
||||||
|
blocked.sort()
|
||||||
|
try saveBlockedNumbers(blocked)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func removeBlockedNumber(_ number: Int64) throws {
|
||||||
|
var blocked = try loadBlockedNumbers()
|
||||||
|
if let index = blocked.firstIndex(of: number) {
|
||||||
|
blocked.remove(at: index)
|
||||||
|
try saveBlockedNumbers(blocked)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadBlockedNumbers() throws -> [Int64] {
|
||||||
|
guard let url = appGroupURL?.appendingPathComponent(blockedNumbersFile) else {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
if !fileManager.fileExists(atPath: url.path) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
let data = try Data(contentsOf: url)
|
||||||
|
return data.withUnsafeBytes { Array($0.bindMemory(to: Int64.self)) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private func saveBlockedNumbers(_ numbers: [Int64]) throws {
|
||||||
|
guard let url = appGroupURL?.appendingPathComponent(blockedNumbersFile) else { return }
|
||||||
|
let data = numbers.withUnsafeBytes { Data($0) }
|
||||||
|
try data.write(to: url, options: .atomic)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Identified Numbers
|
||||||
|
|
||||||
|
func addIdentifiedNumber(_ number: Int64, label: String) throws {
|
||||||
|
var entries = try loadIdentifiedEntries()
|
||||||
|
if let index = entries.firstIndex(where: { $0.number == number }) {
|
||||||
|
entries[index] = IdentifiedEntry(number: number, label: label)
|
||||||
|
} else {
|
||||||
|
entries.append(IdentifiedEntry(number: number, label: label))
|
||||||
|
}
|
||||||
|
// Sort by number to satisfy CallKit requirement
|
||||||
|
entries.sort { $0.number < $1.number }
|
||||||
|
try saveIdentifiedEntries(entries)
|
||||||
|
}
|
||||||
|
|
||||||
|
func removeIdentifiedNumber(_ number: Int64) throws {
|
||||||
|
var entries = try loadIdentifiedEntries()
|
||||||
|
entries.removeAll { $0.number == number }
|
||||||
|
try saveIdentifiedEntries(entries)
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadIdentifiedEntries() throws -> [IdentifiedEntry] {
|
||||||
|
guard let url = appGroupURL?.appendingPathComponent(identifiedEntriesFile) else {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
if !fileManager.fileExists(atPath: url.path) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
let data = try Data(contentsOf: url)
|
||||||
|
return try JSONDecoder().decode([IdentifiedEntry].self, from: data)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func saveIdentifiedEntries(_ entries: [IdentifiedEntry]) throws {
|
||||||
|
guard let url = appGroupURL?.appendingPathComponent(identifiedEntriesFile) else { return }
|
||||||
|
let data = try JSONEncoder().encode(entries)
|
||||||
|
try data.write(to: url, options: .atomic)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Sync and Management
|
||||||
|
|
||||||
|
/// Clears all data. Useful for testing or resetting.
|
||||||
|
func clearAll() throws {
|
||||||
|
guard let url = appGroupURL else { return }
|
||||||
|
let blockedURL = url.appendingPathComponent(blockedNumbersFile)
|
||||||
|
let identifiedURL = url.appendingPathComponent(identifiedEntriesFile)
|
||||||
|
|
||||||
|
if fileManager.fileExists(atPath: blockedURL.path) {
|
||||||
|
try fileManager.removeItem(at: blockedURL)
|
||||||
|
}
|
||||||
|
if fileManager.fileExists(atPath: identifiedURL.path) {
|
||||||
|
try fileManager.removeItem(at: identifiedURL)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -163,8 +163,13 @@
|
|||||||
- [x] **Widget extension** — KordantWidgets with small/medium/large sizes
|
- [x] **Widget extension** — KordantWidgets with small/medium/large sizes
|
||||||
- [x] **Widget privacy manifest** — Separate `PrivacyInfo.xcprivacy` for widget
|
- [x] **Widget privacy manifest** — Separate `PrivacyInfo.xcprivacy` for widget
|
||||||
- [x] **Call Directory extension** — KordantSpamShieldExtension for spam filtering
|
- [x] **Call Directory extension** — KordantSpamShieldExtension for spam filtering
|
||||||
- [x] **App Group configured** — `group.com.frenocorp.kordant` for widget data sharing
|
- [x] **SpamShield Extension target in project.yml** — Added missing target configuration
|
||||||
|
- [x] **SpamShield Extension entitlements** — CallKit + App Group entitlements for shared data
|
||||||
|
- [x] **SpamDirectoryService in shared sources** — Moved to Sources/Shared for extension access
|
||||||
|
- [x] **App Group configured** — `group.com.frenocorp.kordant` for widget & spam data sharing
|
||||||
- [x] **Extension entitlements** — Proper entitlements for widgets and Call Directory
|
- [x] **Extension entitlements** — Proper entitlements for widgets and Call Directory
|
||||||
|
- [x] **No `print()` in production code** — Replaced with OSLog in SpamSettingsView
|
||||||
|
- [x] **CallKit extension status check** — Uses CXCallDirectoryManager instead of `print()`
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -177,11 +182,11 @@
|
|||||||
| Business | 8 | 8 ✅ |
|
| Business | 8 | 8 ✅ |
|
||||||
| Design | 10 | 10 ✅ |
|
| Design | 10 | 10 ✅ |
|
||||||
| Legal | 20 | 20 ✅ |
|
| Legal | 20 | 20 ✅ |
|
||||||
| Technical | 15 | 15 ✅ |
|
| Technical | 17 | 17 ✅ |
|
||||||
| Security | 7 | 7 ✅ |
|
| Security | 7 | 7 ✅ |
|
||||||
| Accessibility | 6 | 6 ✅ |
|
| Accessibility | 6 | 6 ✅ |
|
||||||
| Internationalization | 4 | 4 ✅ |
|
| Internationalization | 4 | 4 ✅ |
|
||||||
| Extensions | 5 | 5 ✅ |
|
| Extensions | 8 | 8 ✅ |
|
||||||
| **Total** | **116** | **116 ✅** |
|
| **Total** | **121** | **121 ✅** |
|
||||||
|
|
||||||
**Result: All 116 compliance items verified. App is ready for App Store submission.**
|
**Result: All 121 compliance items verified. App is ready for App Store submission.**
|
||||||
|
|||||||
@@ -138,11 +138,14 @@
|
|||||||
|
|
||||||
### Call Directory Extension
|
### Call Directory Extension
|
||||||
|
|
||||||
**Risk**: Extension not enabled by reviewer
|
**Risk**: Extension not properly integrated in build
|
||||||
**Mitigation**:
|
**Mitigation**:
|
||||||
|
- ✅ Extension target added to `project.yml` with proper configuration
|
||||||
|
- ✅ Entitlements created (CallKit + App Group) for shared data access
|
||||||
|
- ✅ `SpamDirectoryService` moved to `Sources/Shared` for cross-target access
|
||||||
- ✅ Extension is optional — app works without it
|
- ✅ Extension is optional — app works without it
|
||||||
- ✅ Clear instructions in SpamShield settings
|
- ✅ Clear instructions in SpamShield settings
|
||||||
- ✅ Extension status shown in settings
|
- ✅ Extension status checked via `CXCallDirectoryManager.getEnabledStatusForExtension`
|
||||||
- ✅ Graceful fallback when extension is not enabled
|
- ✅ Graceful fallback when extension is not enabled
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -211,6 +214,8 @@ Before submitting to App Store Review:
|
|||||||
|
|
||||||
- [ ] Build Release configuration
|
- [ ] Build Release configuration
|
||||||
- [ ] Verify no `#if DEBUG` code paths contain visible content
|
- [ ] Verify no `#if DEBUG` code paths contain visible content
|
||||||
|
- [ ] Verify no `print()` calls remain outside `#Preview` blocks
|
||||||
|
- [ ] Verify no force unwraps in production code paths
|
||||||
- [ ] Test on physical device (not just simulator)
|
- [ ] Test on physical device (not just simulator)
|
||||||
- [ ] Verify all deep links work
|
- [ ] Verify all deep links work
|
||||||
- [ ] Verify push notifications work
|
- [ ] Verify push notifications work
|
||||||
@@ -225,6 +230,8 @@ Before submitting to App Store Review:
|
|||||||
- [ ] Verify privacy policy URL works
|
- [ ] Verify privacy policy URL works
|
||||||
- [ ] Verify all screenshots match current app UI
|
- [ ] Verify all screenshots match current app UI
|
||||||
- [ ] Verify app preview video is current
|
- [ ] Verify app preview video is current
|
||||||
|
- [ ] Verify SpamShield extension target built
|
||||||
|
- [ ] Verify SpamShield extension entitlements correct
|
||||||
- [ ] Upload via Xcode Organizer or Transporter
|
- [ ] Upload via Xcode Organizer or Transporter
|
||||||
- [ ] Fill in App Store Connect metadata
|
- [ ] Fill in App Store Connect metadata
|
||||||
- [ ] Add review notes and demo account
|
- [ ] Add review notes and demo account
|
||||||
|
|||||||
@@ -167,6 +167,7 @@ Push notifications deep link to specific screens:
|
|||||||
4. **If biometric is unavailable**: Falls back to password-only authentication
|
4. **If biometric is unavailable**: Falls back to password-only authentication
|
||||||
5. **Offline behavior**: App caches data and syncs when connection is restored
|
5. **Offline behavior**: App caches data and syncs when connection is restored
|
||||||
6. **Error states**: All network failures show user-friendly error messages with retry option
|
6. **Error states**: All network failures show user-friendly error messages with retry option
|
||||||
|
7. **SpamShield Call Extension**: The app includes a Call Directory extension for spam filtering. To enable, go to Settings → Phone → Call Blocking & Identification. The extension is properly configured with App Group entitlements for shared data and its own target in the project.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ targets:
|
|||||||
INFOPLIST_FILE: Info.plist
|
INFOPLIST_FILE: Info.plist
|
||||||
dependencies:
|
dependencies:
|
||||||
- target: KordantWidgets
|
- target: KordantWidgets
|
||||||
|
- target: KordantSpamShieldExtension
|
||||||
- package: Collections
|
- package: Collections
|
||||||
product: Collections
|
product: Collections
|
||||||
- package: Algorithms
|
- package: Algorithms
|
||||||
@@ -101,6 +102,25 @@ targets:
|
|||||||
CODE_SIGN_ENTITLEMENTS: KordantWidgets/KordantWidgets.entitlements
|
CODE_SIGN_ENTITLEMENTS: KordantWidgets/KordantWidgets.entitlements
|
||||||
SWIFT_VERSION: "5.9"
|
SWIFT_VERSION: "5.9"
|
||||||
|
|
||||||
|
KordantSpamShieldExtension:
|
||||||
|
type: app-extension
|
||||||
|
platform: iOS
|
||||||
|
deploymentTarget: "17.0"
|
||||||
|
sources:
|
||||||
|
- path: KordantSpamShieldExtension
|
||||||
|
includes:
|
||||||
|
- "**/*.swift"
|
||||||
|
- "PrivacyInfo.xcprivacy"
|
||||||
|
- path: Sources/Shared
|
||||||
|
settings:
|
||||||
|
base:
|
||||||
|
PRODUCT_BUNDLE_IDENTIFIER: com.frenocorp.kordant.spamshield
|
||||||
|
PRODUCT_NAME: KordantSpamShieldExtension
|
||||||
|
SKIP_INSTALL: YES
|
||||||
|
INFOPLIST_FILE: KordantSpamShieldExtension/Info.plist
|
||||||
|
CODE_SIGN_ENTITLEMENTS: KordantSpamShieldExtension/KordantSpamShieldExtension.entitlements
|
||||||
|
SWIFT_VERSION: "5.9"
|
||||||
|
|
||||||
KordantUITests:
|
KordantUITests:
|
||||||
type: bundle.ui-testing
|
type: bundle.ui-testing
|
||||||
platform: iOS
|
platform: iOS
|
||||||
@@ -120,6 +140,7 @@ schemes:
|
|||||||
targets:
|
targets:
|
||||||
Kordant: all
|
Kordant: all
|
||||||
KordantWidgets: all
|
KordantWidgets: all
|
||||||
|
KordantSpamShieldExtension: all
|
||||||
KordantTests: [test]
|
KordantTests: [test]
|
||||||
KordantUITests: [test]
|
KordantUITests: [test]
|
||||||
run:
|
run:
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ Status legend: [ ] todo, [~] in-progress, [x] done
|
|||||||
- [x] 21 — Real API Client Wiring (Replace StubAPIClient) → `21-real-api-client.md`
|
- [x] 21 — Real API Client Wiring (Replace StubAPIClient) → `21-real-api-client.md`
|
||||||
- [~] 22 — Token Refresh & Session Management → `22-token-refresh.md`
|
- [~] 22 — Token Refresh & Session Management → `22-token-refresh.md`
|
||||||
- [~] 23 — Offline Mode & Sync Conflict Resolution → `23-offline-sync.md`
|
- [~] 23 — Offline Mode & Sync Conflict Resolution → `23-offline-sync.md`
|
||||||
- [~] 24 — Push Notification Deep Linking → `24-push-deep-links.md`
|
- [x] 24 — Push Notification Deep Linking → `24-push-deep-links.md`
|
||||||
|
|
||||||
### App Store Compliance
|
### App Store Compliance
|
||||||
- [x] 25 — Privacy Manifest & Nutrition Labels → `25-privacy-manifest.md`
|
- [x] 25 — Privacy Manifest & Nutrition Labels → `25-privacy-manifest.md`
|
||||||
|
|||||||
Reference in New Issue
Block a user