diff --git a/android/app/src/debug/AndroidManifest.xml b/android/app/src/debug/AndroidManifest.xml index 406eff082..38ddf8976 100644 --- a/android/app/src/debug/AndroidManifest.xml +++ b/android/app/src/debug/AndroidManifest.xml @@ -4,4 +4,6 @@ to allow setting breakpoints, to provide hot reload, etc. --> + + diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 6cf0f5175..b2fbb4151 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -9,6 +9,7 @@ android:name="io.flutter.app.FlutterApplication" android:label="Orma" android:icon="@mipmap/ic_launcher"> + :debug, 'Profile' => :release, diff --git a/ios/Podfile.lock b/ios/Podfile.lock new file mode 100644 index 000000000..fe1475cd8 --- /dev/null +++ b/ios/Podfile.lock @@ -0,0 +1,67 @@ +PODS: + - Flutter (1.0.0) + - FMDB (2.7.5): + - FMDB/standard (= 2.7.5) + - FMDB/standard (2.7.5) + - path_provider (0.0.1): + - Flutter + - path_provider_macos (0.0.1): + - Flutter + - photo_manager (0.0.1): + - Flutter + - shared_preferences (0.0.1): + - Flutter + - shared_preferences_macos (0.0.1): + - Flutter + - shared_preferences_web (0.0.1): + - Flutter + - sqflite (0.0.1): + - Flutter + - FMDB (~> 2.7.2) + +DEPENDENCIES: + - Flutter (from `Flutter`) + - path_provider (from `.symlinks/plugins/path_provider/ios`) + - path_provider_macos (from `.symlinks/plugins/path_provider_macos/ios`) + - photo_manager (from `.symlinks/plugins/photo_manager/ios`) + - shared_preferences (from `.symlinks/plugins/shared_preferences/ios`) + - shared_preferences_macos (from `.symlinks/plugins/shared_preferences_macos/ios`) + - shared_preferences_web (from `.symlinks/plugins/shared_preferences_web/ios`) + - sqflite (from `.symlinks/plugins/sqflite/ios`) + +SPEC REPOS: + trunk: + - FMDB + +EXTERNAL SOURCES: + Flutter: + :path: Flutter + path_provider: + :path: ".symlinks/plugins/path_provider/ios" + path_provider_macos: + :path: ".symlinks/plugins/path_provider_macos/ios" + photo_manager: + :path: ".symlinks/plugins/photo_manager/ios" + shared_preferences: + :path: ".symlinks/plugins/shared_preferences/ios" + shared_preferences_macos: + :path: ".symlinks/plugins/shared_preferences_macos/ios" + shared_preferences_web: + :path: ".symlinks/plugins/shared_preferences_web/ios" + sqflite: + :path: ".symlinks/plugins/sqflite/ios" + +SPEC CHECKSUMS: + Flutter: 0e3d915762c693b495b44d77113d4970485de6ec + FMDB: 2ce00b547f966261cd18927a3ddb07cb6f3db82a + path_provider: fb74bd0465e96b594bb3b5088ee4a4e7bb1f2a9d + path_provider_macos: f760a3c5b04357c380e2fddb6f9db6f3015897e0 + photo_manager: f7c619c2cc8c2adb8d85c63363babac477de9c67 + shared_preferences: 430726339841afefe5142b9c1f50cb6bd7793e01 + shared_preferences_macos: f3f29b71ccbb56bf40c9dd6396c9acf15e214087 + shared_preferences_web: 141cce0c3ed1a1c5bf2a0e44f52d31eeb66e5ea9 + sqflite: 4001a31ff81d210346b500c55b17f4d6c7589dd0 + +PODFILE CHECKSUM: dc81df99923cb3d9115f3b6c3d7e730abfec780b + +COCOAPODS: 1.9.1 diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index 42f2fcc76..6b4e8b3dc 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -7,6 +7,7 @@ objects = { /* Begin PBXBuildFile section */ + 06CC0CFA92976FDBA53FAAE5 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = BB5F531E492C7002EAC4CE42 /* Pods_Runner.framework */; }; 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; }; 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; }; 3B80C3941E831B6300D905FE /* App.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3B80C3931E831B6300D905FE /* App.framework */; }; @@ -39,17 +40,21 @@ 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = ""; }; 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = ""; }; 3B80C3931E831B6300D905FE /* App.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = App.framework; path = Flutter/App.framework; sourceTree = ""; }; + 6A8718E36D8B0CC6360ADB0D /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = ""; }; 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = ""; }; 74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = ""; }; 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = ""; }; 9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = ""; }; 9740EEBA1CF902C7004384FC /* Flutter.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Flutter.framework; path = Flutter/Flutter.framework; sourceTree = ""; }; + 97A6D25F159DA10C77E8A78D /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = ""; }; 97C146EE1CF9000F007C117D /* Runner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Runner.app; sourceTree = BUILT_PRODUCTS_DIR; }; 97C146FB1CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = ""; }; 97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; 97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + A3AB65855D82977B2D2D5B35 /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = ""; }; + BB5F531E492C7002EAC4CE42 /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -59,6 +64,7 @@ files = ( 9705A1C61CF904A100538489 /* Flutter.framework in Frameworks */, 3B80C3941E831B6300D905FE /* App.framework in Frameworks */, + 06CC0CFA92976FDBA53FAAE5 /* Pods_Runner.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -84,6 +90,8 @@ 9740EEB11CF90186004384FC /* Flutter */, 97C146F01CF9000F007C117D /* Runner */, 97C146EF1CF9000F007C117D /* Products */, + AC6CA265BB505D982CB00391 /* Pods */, + C6A22658E77FF012720BEDDA /* Frameworks */, ); sourceTree = ""; }; @@ -118,6 +126,25 @@ name = "Supporting Files"; sourceTree = ""; }; + AC6CA265BB505D982CB00391 /* Pods */ = { + isa = PBXGroup; + children = ( + 97A6D25F159DA10C77E8A78D /* Pods-Runner.debug.xcconfig */, + A3AB65855D82977B2D2D5B35 /* Pods-Runner.release.xcconfig */, + 6A8718E36D8B0CC6360ADB0D /* Pods-Runner.profile.xcconfig */, + ); + name = Pods; + path = Pods; + sourceTree = ""; + }; + C6A22658E77FF012720BEDDA /* Frameworks */ = { + isa = PBXGroup; + children = ( + BB5F531E492C7002EAC4CE42 /* Pods_Runner.framework */, + ); + name = Frameworks; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ @@ -125,12 +152,14 @@ isa = PBXNativeTarget; buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */; buildPhases = ( + 519725EB629C2C7897CFF552 /* [CP] Check Pods Manifest.lock */, 9740EEB61CF901F6004384FC /* Run Script */, 97C146EA1CF9000F007C117D /* Sources */, 97C146EB1CF9000F007C117D /* Frameworks */, 97C146EC1CF9000F007C117D /* Resources */, 9705A1C41CF9048500538489 /* Embed Frameworks */, 3B06AD1E1E4923F5004D2608 /* Thin Binary */, + 481FE5B058006945E0569431 /* [CP] Embed Pods Frameworks */, ); buildRules = ( ); @@ -205,6 +234,43 @@ shellPath = /bin/sh; shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" thin"; }; + 481FE5B058006945E0569431 /* [CP] Embed Pods Frameworks */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + ); + name = "[CP] Embed Pods Frameworks"; + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n"; + showEnvVarsInLog = 0; + }; + 519725EB629C2C7897CFF552 /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-Runner-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; 9740EEB61CF901F6004384FC /* Run Script */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; diff --git a/ios/Runner.xcworkspace/contents.xcworkspacedata b/ios/Runner.xcworkspace/contents.xcworkspacedata index 1d526a16e..21a3cc14c 100644 --- a/ios/Runner.xcworkspace/contents.xcworkspacedata +++ b/ios/Runner.xcworkspace/contents.xcworkspacedata @@ -4,4 +4,7 @@ + + diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist index 18a831a21..98037e789 100644 --- a/ios/Runner/Info.plist +++ b/ios/Runner/Info.plist @@ -41,5 +41,7 @@ UIViewControllerBasedStatusBarAppearance + NSPhotoLibraryUsageDescription + Photo Library Access Warning diff --git a/lib/db/db_helper.dart b/lib/db/db_helper.dart index 276b69ec2..25b806ce1 100644 --- a/lib/db/db_helper.dart +++ b/lib/db/db_helper.dart @@ -5,14 +5,15 @@ import 'package:sqflite/sqflite.dart'; import 'package:path_provider/path_provider.dart'; class DatabaseHelper { - static final _databaseName = "Orma.db"; + static final _databaseName = "orma.db"; static final _databaseVersion = 1; - static final table = 'synced_photos'; - + static final table = 'uploaded_photos'; + static final columnId = 'photo_id'; - static final columnLocalUrl = 'local_url'; - static final columnTimestamp = 'timestamp'; + static final columnPath = 'path'; + static final columnHash = 'hash'; + static final columnUploadTimestamp = 'upload_timestamp'; // make this a singleton class DatabaseHelper._privateConstructor(); @@ -26,27 +27,38 @@ class DatabaseHelper { _database = await _initDatabase(); return _database; } - + // this opens the database (and creates it if it doesn't exist) _initDatabase() async { Directory documentsDirectory = await getApplicationDocumentsDirectory(); String path = join(documentsDirectory.path, _databaseName); return await openDatabase(path, - version: _databaseVersion, - onCreate: _onCreate); + version: _databaseVersion, onCreate: _onCreate); } // SQL code to create the database table Future _onCreate(Database db, int version) async { await db.execute(''' CREATE TABLE $table ( - $columnId INTEGER PRIMARY KEY, - $columnLocalUrl TEXT NOT NULL, - $columnTimestamp INTEGER NOT NULL + $columnId VARCHAR(255) PRIMARY KEY, + $columnPath TEXT NOT NULL, + $columnHash TEXT NOT NULL, + $columnUploadTimestamp TEXT NOT NULL ) '''); } - + + Future insertPhoto( + String photoID, String path, String hash, int uploadTimestamp) async { + Database db = await instance.database; + var row = new Map(); + row[columnId] = photoID; + row[columnPath] = path; + row[columnHash] = hash; + row[columnUploadTimestamp] = uploadTimestamp; + return await db.insert(table, row); + } + // Helper methods // Inserts a row in the database where each key in the Map is a column name @@ -57,21 +69,14 @@ class DatabaseHelper { return await db.insert(table, row); } - // All of the rows are returned as a list of maps, where each map is + // All of the rows are returned as a list of maps, where each map is // a key-value list of columns. Future>> queryAllRows() async { Database db = await instance.database; return await db.query(table); } - // All of the methods (insert, query, update, delete) can also be done using - // raw SQL commands. This method uses a raw query to give the row count. - Future queryLastTimestamp() async { - Database db = await instance.database; - return Sqflite.firstIntValue(await db.rawQuery('SELECT MAX($columnTimestamp) FROM $table')); - } - - // We are assuming here that the id column in the map is set. The other + // We are assuming here that the id column in the map is set. The other // column values will be used to update the row. Future update(Map row) async { Database db = await instance.database; @@ -79,10 +84,17 @@ class DatabaseHelper { return await db.update(table, row, where: '$columnId = ?', whereArgs: [id]); } - // Deletes the row specified by the id. The number of affected rows is + // Deletes the row specified by the id. The number of affected rows is // returned. This should be 1 as long as the row exists. Future delete(int id) async { Database db = await instance.database; return await db.delete(table, where: '$columnId = ?', whereArgs: [id]); } -} \ No newline at end of file + + Future containsPath(String path) async { + Database db = await instance.database; + return (await db.query(table, where: '$columnPath =?', whereArgs: [path])) + .length > + 0; + } +} diff --git a/lib/photo_sync_manager.dart b/lib/photo_sync_manager.dart index ea6e631e2..8aec6dbca 100644 --- a/lib/photo_sync_manager.dart +++ b/lib/photo_sync_manager.dart @@ -1,13 +1,28 @@ import 'package:logger/logger.dart'; +import 'package:myapp/db/db_helper.dart'; +import 'package:path/path.dart'; +import 'package:path_provider/path_provider.dart'; import 'package:photo_manager/photo_manager.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:dio/dio.dart'; +class RemotePhoto { + String photoID; + int syncTimestamp; + String url; + + RemotePhoto.fromJson(Map json) + : photoID = json["photoID"], + syncTimestamp = json["syncTimestamp"], + url = json["url"]; +} + class PhotoSyncManager { final logger = Logger(); final dio = Dio(); - final uploadUrl = "http://192.168.0.106:8080/upload"; - static final lastUploadedItemTimestampKey = "last_uploaded_item_timestamp_5"; + final endpoint = "http://192.168.0.106:8080"; + final user = "umbu"; + static final lastSyncTimestampKey = "last_sync_timestamp_0"; PhotoSyncManager(List assets) { logger.i("PhotoSyncManager init"); @@ -16,35 +31,69 @@ class PhotoSyncManager { _syncPhotos(List assets) async { SharedPreferences prefs = await SharedPreferences.getInstance(); - var lastSyncTimestamp = prefs.getInt(lastUploadedItemTimestampKey); + var lastSyncTimestamp = prefs.getInt(lastSyncTimestampKey); if (lastSyncTimestamp == null) { lastSyncTimestamp = 0; } logger.i("Last sync timestamp: " + lastSyncTimestamp.toString()); - assets.sort((a, b) => a.modifiedDateTime.millisecondsSinceEpoch - .compareTo(b.modifiedDateTime.millisecondsSinceEpoch)); + + await _downloadDiff(lastSyncTimestamp, prefs); + + await _uploadDiff(assets, prefs); + + // TODO: Fix race conditions triggered due to concurrent syncs. + // Add device_id/last_sync_timestamp to the upload request? + } + + Future _uploadDiff(List assets, SharedPreferences prefs) async { + assets.sort((first, second) => second + .modifiedDateTime.millisecondsSinceEpoch + .compareTo(first.modifiedDateTime.millisecondsSinceEpoch)); for (AssetEntity asset in assets) { - if (asset.modifiedDateTime.millisecondsSinceEpoch > lastSyncTimestamp) { - var response = await _uploadFile(asset); - if (response.statusCode == 200) { - prefs.setInt(lastUploadedItemTimestampKey, - asset.modifiedDateTime.millisecondsSinceEpoch); - logger.i("Updated for: " + asset.id); + DatabaseHelper.instance + .containsPath((await asset.originFile).path) + .then((containsPath) async { + if (!containsPath) { + var response = await _uploadFile(asset); + prefs.setInt(lastSyncTimestampKey, response.syncTimestamp); } - } + }); } } - Future> _uploadFile(AssetEntity entity) async { - logger.i("Uploading: " + entity.id); - var formData = FormData.fromMap({ - "file": await MultipartFile.fromFile((await entity.originFile).path, - filename: entity.title), - "user": "umbu", - "timestamp": entity.modifiedDateTime.millisecondsSinceEpoch + Future _downloadDiff(int lastSyncTimestamp, SharedPreferences prefs) async { + Response response = await dio.get(endpoint + "/diff", queryParameters: { + "user": user, + "lastSyncTimestamp": lastSyncTimestamp }); - var response = await dio.post(uploadUrl, data: formData); + var externalPath = (await getExternalStorageDirectory()).path; + logger.i("External path: " + externalPath); + var path = externalPath + "/photos/"; + + List photos = (response.data["diff"] as List) + .map((photo) => new RemotePhoto.fromJson(photo)) + .toList(); + for (RemotePhoto photo in photos) { + logger.i("Downloading " + endpoint + photo.url + " to " + path); + await dio.download(endpoint + photo.url, path + basename(photo.url)); + prefs.setInt(lastSyncTimestampKey, photo.syncTimestamp); + logger.i("Downloaded " + photo.url + " to " + path); + } + } + + Future _uploadFile(AssetEntity entity) async { + logger.i("Uploading: " + entity.id); + var path = (await entity.originFile).path; + var formData = FormData.fromMap({ + "file": await MultipartFile.fromFile(path, filename: entity.title), + "user": user, + }); + var response = await dio.post(endpoint + "/upload", data: formData); logger.i(response.toString()); - return response; + var remotePhoto = RemotePhoto.fromJson(response.data); + // TODO: Compute hash + DatabaseHelper.instance.insertPhoto( + remotePhoto.photoID, path, "hash", remotePhoto.syncTimestamp); + return remotePhoto; } }