261 lines
9.1 KiB
Go
261 lines
9.1 KiB
Go
/*
|
|
Timelinize
|
|
Copyright (c) 2013 Matthew Holt
|
|
|
|
This program is free software: you can redistribute it and/or modify
|
|
it under the terms of the GNU Affero General Public License as published
|
|
by the Free Software Foundation, either version 3 of the License, or
|
|
(at your option) any later version.
|
|
|
|
This program is distributed in the hope that it will be useful,
|
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
GNU Affero General Public License for more details.
|
|
|
|
You should have received a copy of the GNU Affero General Public License
|
|
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
*/
|
|
|
|
// Package iphone implements a data source for iPhone backups. It supports
|
|
// the camera roll, messages, and address book, along with basic device info.
|
|
package iphone
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"fmt"
|
|
"io"
|
|
"io/fs"
|
|
"os"
|
|
"path"
|
|
"path/filepath"
|
|
"sync"
|
|
|
|
"github.com/timelinize/timelinize/datasources/imessage"
|
|
"github.com/timelinize/timelinize/timeline"
|
|
"go.uber.org/zap"
|
|
"howett.net/plist"
|
|
)
|
|
|
|
/*
|
|
TODO: Other options/ideas for this data source:
|
|
- Scan files for recognized types, like .vcf (vCards) and just import them
|
|
- Interesting file: CameraRollDomain: Media/PhotoData/Photos.sqlite (12b144c0bd44f2b3dffd9186d3f9c05b917cee25 in my sample) -- tons of granular details including face recognition
|
|
- Potentially useful files with telephone number:
|
|
bf01197b61bd101045f3421ec28b29e8cc0d3930 also <-- HomeDomain: Library/Preferences/com.apple.imservice.SMS.plist
|
|
bfecaa9c467e3acb085a5b312bd27bdd5cd7579a also <-- WirelessDomain: Library/Preferences/com.apple.commcenter.plist
|
|
|
|
*/
|
|
|
|
func init() {
|
|
err := timeline.RegisterDataSource(timeline.DataSource{
|
|
Name: "iphone",
|
|
Title: "iPhone",
|
|
Icon: "iphone.svg",
|
|
NewOptions: func() any { return new(Options) },
|
|
NewFileImporter: func() timeline.FileImporter { return &FileImporter{manifestMu: new(sync.Mutex)} },
|
|
})
|
|
if err != nil {
|
|
timeline.Log.Fatal("registering data source", zap.Error(err))
|
|
}
|
|
}
|
|
|
|
// FileImporter can import iPhone backups from a folder on disk.
|
|
type FileImporter struct {
|
|
opt timeline.ImportParams
|
|
dsOpt *Options
|
|
|
|
// these change as we iterate the list of input folders
|
|
root string
|
|
manifest *sql.DB
|
|
manifestMu *sync.Mutex // (this obviously doesn't change)
|
|
devicePhoneNumber string
|
|
owner *timeline.Entity
|
|
}
|
|
|
|
// Recognize returns whether the folder is recognized.
|
|
func (FileImporter) Recognize(_ context.Context, dirEntry timeline.DirEntry, _ timeline.RecognizeParams) (timeline.Recognition, error) {
|
|
var found, total int
|
|
for _, filename := range []string{
|
|
"Manifest.db",
|
|
"Manifest.plist",
|
|
"Info.plist",
|
|
"Status.plist",
|
|
} {
|
|
total++
|
|
if dirEntry.FileExists(filename) {
|
|
found++
|
|
} else if filename == "Manifest.db" {
|
|
return timeline.Recognition{}, nil // the manifest DB is absolutely required
|
|
}
|
|
}
|
|
return timeline.Recognition{Confidence: float64(found) / float64(total)}, nil
|
|
}
|
|
|
|
// FileImport imports data from the given folder.
|
|
func (fimp *FileImporter) FileImport(ctx context.Context, dirEntry timeline.DirEntry, params timeline.ImportParams) error {
|
|
fimp.dsOpt = params.DataSourceOptions.(*Options)
|
|
fimp.opt = params
|
|
fimp.root = dirEntry.FSRoot
|
|
|
|
// we can't open a SQLite DB using io/fs interfaces, so we have to just utilize disk directly.
|
|
dbPath := filepath.Join(fimp.root, filepath.FromSlash(dirEntry.Filename), "Manifest.db")
|
|
db, err := sql.Open("sqlite3", dbPath+"?mode=ro")
|
|
if err != nil {
|
|
return fmt.Errorf("opening manifest DB: %w", err)
|
|
}
|
|
defer db.Close()
|
|
|
|
fimp.manifest = db
|
|
|
|
// load device's current/latest phone number in case we need it (I haven't seen much need for it,
|
|
// but I already wrote the code before I discovered the easier source of the phone number)
|
|
commCenter, err := fimp.loadCommCenter(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
fimp.devicePhoneNumber = imessage.NormalizePhoneNumber(ctx, commCenter.phoneNumber(), "")
|
|
if fimp.devicePhoneNumber != "" {
|
|
fimp.owner = &timeline.Entity{
|
|
Attributes: []timeline.Attribute{
|
|
{
|
|
Name: timeline.AttributePhoneNumber,
|
|
Value: fimp.devicePhoneNumber,
|
|
Identity: true,
|
|
},
|
|
},
|
|
}
|
|
}
|
|
|
|
if err = fimp.addressBook(ctx); err != nil {
|
|
return fmt.Errorf("importing iPhone address book: %w", err)
|
|
}
|
|
if err = fimp.messages(ctx); err != nil {
|
|
return fmt.Errorf("importing iPhone messages: %w", err)
|
|
}
|
|
// importing the camera roll directly just iterates the DCIM folder in the file system for photos and videos,
|
|
// whereas importing the photo library _almost_ (?) does the same thing but with more structure and metadata
|
|
if fimp.dsOpt.CameraRoll {
|
|
if err = fimp.cameraRoll(ctx); err != nil {
|
|
return fmt.Errorf("importing iPhone camera roll: %w", err)
|
|
}
|
|
} else {
|
|
if err = fimp.photosLibrary(ctx, dirEntry); err != nil {
|
|
return fmt.Errorf("importing iPhone Photos app library: %w", err)
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// openFile opens a file given its domain and path. The manifest DB maps domain+path to file ID.
|
|
// We look up the file ID from the manifest and then use that to open the file and return it.
|
|
func (fimp *FileImporter) openFile(ctx context.Context, domain, relativePath string) (fs.File, error) {
|
|
fileID, err := fimp.findFileID(ctx, domain, relativePath)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return os.Open(fimp.fileIDToPath(fileID))
|
|
}
|
|
|
|
// findFileID looks up the file ID in the manifest for the file given by its domain and path relative to that domain.
|
|
func (fimp *FileImporter) findFileID(ctx context.Context, domain, relativePath string) (string, error) {
|
|
fimp.manifestMu.Lock()
|
|
defer fimp.manifestMu.Unlock()
|
|
|
|
var id *string
|
|
err := fimp.manifest.QueryRowContext(ctx,
|
|
"SELECT fileID FROM Files WHERE domain=? AND relativePath=? LIMIT 1",
|
|
domain, relativePath).Scan(&id)
|
|
if err != nil {
|
|
return "", fmt.Errorf("looking up fileID for relative path '%s' in domain '%s': %w", relativePath, domain, err)
|
|
}
|
|
|
|
if id != nil {
|
|
return *id, nil
|
|
}
|
|
return "", nil
|
|
}
|
|
|
|
// fileIDToPath converts a fileID (the checksum by which it is organized in the backup) to
|
|
// the absolute path it can be accessed on disk. The return value uses the OS path separator.
|
|
func (fimp FileImporter) fileIDToPath(fileID string) string {
|
|
return filepath.Join(fimp.root, filepath.FromSlash(fimp.fileIDToRelativePath(fileID)))
|
|
}
|
|
|
|
// fileIDToRelativePath returns the path to the file relative to the root folder of the backup.
|
|
// The term "relative path" here is NOT the same as the "relativePath" field in the manifest DB.
|
|
// This relative path is relative to the backup root here, NOT the original path on the iPhone.
|
|
// The return value uses the forward slash ("/") as a path separator (as used with fs.FS).
|
|
func (fimp FileImporter) fileIDToRelativePath(fileID string) string {
|
|
const requiredLen = 2
|
|
if len(fileID) < requiredLen {
|
|
return fileID // I dunno; this is probably safer than an empty path element?
|
|
}
|
|
return path.Join(fileID[:2], fileID)
|
|
}
|
|
|
|
// loadCommCenter loads information about the device's wireless connectivity / cellular service.
|
|
func (fimp FileImporter) loadCommCenter(ctx context.Context) (commCenterInfo, error) {
|
|
commCenterPList, err := fimp.openFile(ctx, wirelessDomain, commCenterFile)
|
|
if err != nil {
|
|
return commCenterInfo{}, err
|
|
}
|
|
defer commCenterPList.Close()
|
|
|
|
commCenterPListSeeker, ok := commCenterPList.(io.ReadSeeker)
|
|
if !ok {
|
|
return commCenterInfo{}, fmt.Errorf("commcenter.plist file is not seekable :( %T", commCenterPList)
|
|
}
|
|
|
|
dec := plist.NewDecoder(commCenterPListSeeker)
|
|
|
|
var commCenter commCenterInfo
|
|
if err := dec.Decode(&commCenter); err != nil {
|
|
return commCenterInfo{}, fmt.Errorf("decoding commcenter.plist file: %w", err)
|
|
}
|
|
|
|
return commCenter, nil
|
|
}
|
|
|
|
// domainFS is a fs.FS and fs.StatFS for a specific domain in an iPhone backup.
|
|
type domainFS struct {
|
|
fi *FileImporter
|
|
domain string
|
|
}
|
|
|
|
func (dfs domainFS) Open(name string) (fs.File, error) {
|
|
return dfs.fi.openFile(context.Background(), dfs.domain, name)
|
|
}
|
|
|
|
func (dfs domainFS) Stat(name string) (fs.FileInfo, error) {
|
|
fileID, err := dfs.fi.findFileID(context.Background(), dfs.domain, name)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return os.Stat(dfs.fi.fileIDToPath(fileID))
|
|
}
|
|
|
|
type commCenterInfo struct {
|
|
SIMPhoneNumber string `plist:"SIMPhoneNumber"`
|
|
PhoneNumber string `plist:"PhoneNumber"`
|
|
}
|
|
|
|
func (c commCenterInfo) phoneNumber() string {
|
|
phone := c.SIMPhoneNumber
|
|
if phone == "" {
|
|
phone = c.PhoneNumber
|
|
}
|
|
return phone
|
|
}
|
|
|
|
const (
|
|
homeDomain = "HomeDomain" // seems to be like the "default" or "system" domain
|
|
cameraRollDomain = "CameraRollDomain" // stores user photos/videos and adjustments, thumbnails
|
|
wirelessDomain = "WirelessDomain" // information related to cellular service/connectivity
|
|
mediaDomain = "MediaDomain" // contains message attachments
|
|
|
|
relativePathContactsDB = "Library/AddressBook/AddressBook.sqlitedb" // fileID for me: 31bb7ba8914766d4ba40d6dfb6113c8b614be442
|
|
relativePathMessagesDB = "Library/SMS/sms.db" // fileID for me: 3d0d7e5fb2ce288813306e4d4636395e047a3d28
|
|
commCenterFile = "Library/Preferences/com.apple.commcenter.plist" // fileID for me: bfecaa9c467e3acb085a5b312bd27bdd5cd7579a
|
|
)
|