Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 10 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,16 +13,21 @@ StorageLens 是一个原生 iPhone 应用,用来帮助用户查看照片图库
## 它能做什么

- 分析用户已授权的照片图库。
- 找出大视频、屏幕截图、旧媒体和可能相似的照片。
- 按月份整理截图,支持多选和删除确认。
- 首页汇总图库估算占用、分类数量、最近扫描时间和本机隐私状态。
- 找出大视频、可能的屏幕录制、屏幕截图、Live Photos、旧媒体和可能相似的照片。
- 按月份整理截图,支持月份批量选择和年龄筛选。
- 用 Review Basket 汇总待处理项目,只有在最终确认页才会请求系统删除。
- 用月份时间线展示哪些月份可能占用最多空间。
- 对文件大小使用本机快速估算,避免为了排序或首页统计读取完整照片、视频数据。
- 删除前始终要求用户明确确认。

## 它不能做什么

- 不清理 iOS 系统数据。
- 不清理其他 App 的缓存。
- 不声称“清理系统垃圾”“加速 iPhone”或“删除隐藏缓存”
- 不处理 iOS 系统数据。
- 不读取或处理其他 App 的缓存。
- 不提供性能加速、系统级维护或后台缓存管理功能
- 不会自动删除照片或视频。
- 不会绕过系统照片权限,也不会访问其他 App 的数据。

## 隐私模型

Expand Down
8 changes: 4 additions & 4 deletions StorageLens.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -380,7 +380,7 @@
ASSETCATALOG_COMPILER_ACCENT_COLOR_NAME = AccentColor;
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
CURRENT_PROJECT_VERSION = 2;
DEVELOPMENT_ASSET_PATHS = "";
DEVELOPMENT_TEAM = FCKB5654HM;
ENABLE_PREVIEWS = YES;
Expand All @@ -399,7 +399,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.0;
MARKETING_VERSION = 1.1;
PRODUCT_BUNDLE_IDENTIFIER = com.jonasxzb.storagelens;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_EMIT_LOC_STRINGS = YES;
Expand All @@ -414,7 +414,7 @@
ASSETCATALOG_COMPILER_ACCENT_COLOR_NAME = AccentColor;
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
CURRENT_PROJECT_VERSION = 2;
DEVELOPMENT_ASSET_PATHS = "";
DEVELOPMENT_TEAM = FCKB5654HM;
GENERATE_INFOPLIST_FILE = YES;
Expand All @@ -432,7 +432,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.0;
MARKETING_VERSION = 1.1;
PRODUCT_BUNDLE_IDENTIFIER = com.jonasxzb.storagelens;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_EMIT_LOC_STRINGS = YES;
Expand Down
11 changes: 6 additions & 5 deletions StorageLens/Components/AppComponents.swift
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ struct CleanupCategoryRow: View {
Text("\(category.itemCount)")
.font(.headline)
if let estimatedBytes = category.estimatedBytes {
Text(AppFormatters.fileSize(estimatedBytes))
Text("估算 \(AppFormatters.fileSize(estimatedBytes))")
.font(.caption)
.foregroundStyle(.secondary)
}
Expand Down Expand Up @@ -93,8 +93,8 @@ struct MediaAssetRow: View {
.foregroundStyle(.secondary)

HStack(spacing: 8) {
Text(AppFormatters.fileSize(item.estimatedFileSize))
if item.kind == .video {
Text("估算 \(AppFormatters.fileSize(item.estimatedFileSize))")
if item.kind == .video || item.kind == .screenRecording {
Text(AppFormatters.duration(item.duration))
}
Text(item.creationDate.map(AppFormatters.date) ?? "日期未知")
Expand Down Expand Up @@ -193,6 +193,7 @@ struct SelectionSummaryBar: View {
let estimatedBytes: Int64
let isWorking: Bool
let actionTitle: String
var actionSystemImage = "tray.and.arrow.down"
let action: () -> Void

var body: some View {
Expand All @@ -201,7 +202,7 @@ struct SelectionSummaryBar: View {
VStack(alignment: .leading, spacing: 2) {
Text("已选择 \(selectedCount) 项")
.font(.headline)
Text("预计 \(AppFormatters.fileSize(estimatedBytes))")
Text("估算 \(AppFormatters.fileSize(estimatedBytes))")
.font(.caption)
.foregroundStyle(.secondary)
}
Expand All @@ -212,7 +213,7 @@ struct SelectionSummaryBar: View {
if isWorking {
ProgressView()
} else {
Label(actionTitle, systemImage: "trash")
Label(actionTitle, systemImage: actionSystemImage)
}
}
.buttonStyle(.borderedProminent)
Expand Down
147 changes: 132 additions & 15 deletions StorageLens/Models/AppModels.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ enum MediaAssetKind: String, Hashable {
case photo
case video
case screenshot
case screenRecording
case livePhoto

var title: String {
switch self {
Expand All @@ -14,6 +16,10 @@ enum MediaAssetKind: String, Hashable {
return "视频"
case .screenshot:
return "屏幕截图"
case .screenRecording:
return "可能的屏幕录制"
case .livePhoto:
return "Live Photo"
}
}

Expand All @@ -25,6 +31,10 @@ enum MediaAssetKind: String, Hashable {
return "Video"
case .screenshot:
return "Screenshot"
case .screenRecording:
return "Possible Screen Recording"
case .livePhoto:
return "Live Photo"
}
}

Expand All @@ -36,6 +46,10 @@ enum MediaAssetKind: String, Hashable {
return "video"
case .screenshot:
return "rectangle.on.rectangle"
case .screenRecording:
return "record.circle"
case .livePhoto:
return "livephoto"
}
}
}
Expand All @@ -56,17 +70,36 @@ struct MediaAssetItem: Identifiable, Hashable {

var accessibilityLabel: String {
let dateText = creationDate.map(AppFormatters.date) ?? "日期未知"
return "\(kind.title),\(dateText),\(AppFormatters.fileSize(estimatedFileSize))"
return "\(kind.title),\(dateText),估算大小 \(AppFormatters.fileSize(estimatedFileSize))"
}
}

enum CleanupCategoryKind: String, CaseIterable, Identifiable {
case largeVideos
case screenshots
case similarPhotos
case screenRecordings
case livePhotos
case oldMedia
case similarPhotos

var id: String { rawValue }

var title: String {
switch self {
case .largeVideos:
return "大视频"
case .screenshots:
return "屏幕截图"
case .screenRecordings:
return "可能的屏幕录制"
case .livePhotos:
return "Live Photos"
case .oldMedia:
return "旧媒体"
case .similarPhotos:
return "相似照片"
}
}
}

struct CleanupCategory: Identifiable, Hashable {
Expand All @@ -85,15 +118,33 @@ struct ScanSummary: Hashable {
let totalPhotos: Int
let totalVideos: Int
let screenshotCount: Int
let screenRecordingCount: Int
let livePhotoCount: Int
let largeVideoCount: Int
let similarGroupCount: Int
let oldMediaCount: Int
let estimatedCleanableBytes: Int64
let estimatedLargeVideoBytes: Int64
let estimatedScreenshotBytes: Int64
let estimatedScreenRecordingBytes: Int64
let estimatedLivePhotoBytes: Int64
let estimatedOldMediaBytes: Int64
let estimatedSimilarPhotoBytes: Int64
let timelineMonths: [StorageTimelineMonth]
let generatedAt: Date

var estimatedCleanableBytes: Int64 {
estimatedLargeVideoBytes + estimatedScreenshotBytes
var estimatedLibraryBytes: Int64 {
timelineMonths.map(\.estimatedBytes).reduce(0, +)
}

var topTimelineMonths: [StorageTimelineMonth] {
timelineMonths.sorted { $0.estimatedBytes > $1.estimatedBytes }.prefix(3).map { $0 }
}

var insightText: String? {
guard let topMonth = topTimelineMonths.first, topMonth.estimatedBytes > 0 else { return nil }
let source = topMonth.estimatedVideoBytes >= topMonth.estimatedPhotoBytes ? "视频" : "照片"
return "\(topMonth.title) 新增估算空间主要来自\(source)。"
}

var categories: [CleanupCategory] {
Expand All @@ -102,7 +153,7 @@ struct ScanSummary: Hashable {
kind: .largeVideos,
title: "大视频",
englishTitle: "Large Videos",
detail: "优先查看占用空间较大的视频",
detail: "优先查看估算占用较大的视频",
systemImage: "video.fill",
itemCount: largeVideoCount,
estimatedBytes: estimatedLargeVideoBytes
Expand All @@ -111,19 +162,28 @@ struct ScanSummary: Hashable {
kind: .screenshots,
title: "屏幕截图",
englishTitle: "Screenshots",
detail: "按月份整理截图,手动选择删除",
detail: "按月份整理截图,加入篮子后确认",
systemImage: "rectangle.on.rectangle",
itemCount: screenshotCount,
estimatedBytes: estimatedScreenshotBytes
),
CleanupCategory(
kind: .similarPhotos,
title: "相似照片",
englishTitle: "Similar Photos",
detail: "按时间和尺寸线索找出可能相似的照片",
systemImage: "square.stack.3d.up",
itemCount: similarGroupCount,
estimatedBytes: nil
kind: .screenRecordings,
title: "可能的屏幕录制",
englishTitle: "Possible Screen Recordings",
detail: "优先使用图库标记,并结合本机线索谨慎识别",
systemImage: "record.circle",
itemCount: screenRecordingCount,
estimatedBytes: estimatedScreenRecordingBytes
),
CleanupCategory(
kind: .livePhotos,
title: "Live Photos",
englishTitle: "Live Photos",
detail: "只支持查看和手动选择,不转换格式",
systemImage: "livephoto",
itemCount: livePhotoCount,
estimatedBytes: estimatedLivePhotoBytes
),
CleanupCategory(
kind: .oldMedia,
Expand All @@ -132,12 +192,30 @@ struct ScanSummary: Hashable {
detail: "查看较久以前的照片和视频",
systemImage: "calendar",
itemCount: oldMediaCount,
estimatedBytes: nil
estimatedBytes: estimatedOldMediaBytes
),
CleanupCategory(
kind: .similarPhotos,
title: "相似照片",
englishTitle: "Similar Photos",
detail: "按时间和尺寸线索找出可能相似的照片",
systemImage: "square.stack.3d.up",
itemCount: similarGroupCount,
estimatedBytes: estimatedSimilarPhotoBytes
)
]
}
}

struct StorageTimelineMonth: Identifiable, Hashable {
let id: String
let title: String
let estimatedBytes: Int64
let estimatedPhotoBytes: Int64
let estimatedVideoBytes: Int64
let itemCount: Int
}

struct ScreenshotMonthGroup: Identifiable, Hashable {
let id: String
let title: String
Expand All @@ -154,10 +232,49 @@ struct SimilarPhotoGroup: Identifiable, Hashable {
let items: [MediaAssetItem]

var recommendedKeepID: String? {
items.first?.id
items.max { lhs, rhs in
(
lhs.pixelWidth * lhs.pixelHeight,
lhs.estimatedFileSize ?? 0,
lhs.creationDate ?? .distantPast
) < (
rhs.pixelWidth * rhs.pixelHeight,
rhs.estimatedFileSize ?? 0,
rhs.creationDate ?? .distantPast
)
}?.id
}

var estimatedDuplicateBytes: Int64 {
let keepID = recommendedKeepID
return items
.filter { $0.id != keepID }
.compactMap(\.estimatedFileSize)
.reduce(0, +)
}
}

struct ReviewBasketItem: Identifiable, Hashable {
let item: MediaAssetItem
let categoryKinds: Set<CleanupCategoryKind>

var id: String { item.id }

var categoryTitles: String {
CleanupCategoryKind.allCases
.filter { categoryKinds.contains($0) }
.map(\.title)
.joined(separator: "、")
}
}

struct CleanupResult: Identifiable, Hashable {
let id = UUID()
let deletedCount: Int
let unavailableCount: Int
let estimatedBytes: Int64
}

struct UserMessage: Identifiable {
let id = UUID()
let title: String
Expand Down
11 changes: 9 additions & 2 deletions StorageLens/Services/PhotoDeletionService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,16 @@ import Foundation
import Photos

final class PhotoDeletionService {
func deleteAssets(withLocalIdentifiers identifiers: Set<String>) async throws {
guard !identifiers.isEmpty else { return }
func deleteAssets(withLocalIdentifiers identifiers: Set<String>) async throws -> Set<String> {
guard !identifiers.isEmpty else { return [] }

let fetchResult = PHAsset.fetchAssets(withLocalIdentifiers: Array(identifiers), options: nil)
var resolvedIdentifiers: Set<String> = []
fetchResult.enumerateObjects { asset, _, _ in
resolvedIdentifiers.insert(asset.localIdentifier)
}
guard !resolvedIdentifiers.isEmpty else { return [] }

try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, Error>) in
PHPhotoLibrary.shared().performChanges {
PHAssetChangeRequest.deleteAssets(fetchResult)
Expand All @@ -19,5 +25,6 @@ final class PhotoDeletionService {
}
}
}
return resolvedIdentifiers
}
}
Loading