diff --git a/Release checklist.md b/Release checklist.md index 475b1f1..fe237ae 100644 --- a/Release checklist.md +++ b/Release checklist.md @@ -16,7 +16,9 @@ - [ ] Copy changelog in to GitHub - [ ] Attach binaries to GitHub - [ ] cp build/app/outputs/flutter-apk/app-release.apk daily_diary.apk - - [ ] cp -r build/linux/x64/release/bundle/ linux/ - - [ ] tar -czvf linux.tar.gz linux/ - - [ ] rm linux/ + - [ ] cp -r build/linux/x64/release/bundle/ Daily-Diary/ + - [ ] tar -czvf linux.tar.gz Daily-Diary/ + - [ ] rm -r Daily-Diary/ - [ ] Publish +- [ ] rm linux.tar.gz daily_diary.apk +- [ ] git checkout main -f diff --git a/android/app/build.gradle b/android/app/build.gradle index 0266b33..6cfd9ad 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -50,7 +50,7 @@ android { defaultConfig { applicationId "com.voklen.daily_diary" - minSdkVersion flutter.minSdkVersion + minSdkVersion 21 targetSdkVersion flutter.targetSdkVersion versionCode flutterVersionCode.toInteger() versionName flutterVersionName diff --git a/lib/main.dart b/lib/main.dart index f049093..462eee8 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -6,11 +6,13 @@ import 'package:daily_diary/storage.dart'; import 'package:daily_diary/themes.dart'; import 'package:daily_diary/screens/home.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + // This will be removed when widgets can react to spell check changes bool? startupCheckSpelling; -String? savePath; -String? startupSavePath; +SavePath? savePath; +SavePath? startupSavePath; main() async { savePath = await getPath(); @@ -29,7 +31,8 @@ main() async { class App extends StatelessWidget { const App({Key? key}) : super(key: key); - static final SettingsNotifier settingsNotifier = SettingsNotifier(savePath!); + static final settingsNotifier = SettingsNotifier(savePath!); + static final preferences = SharedPreferences.getInstance(); @override Widget build(BuildContext context) { diff --git a/lib/path.dart b/lib/path.dart index 26b4172..fa040ef 100644 --- a/lib/path.dart +++ b/lib/path.dart @@ -1,23 +1,96 @@ +import 'dart:convert'; import 'dart:io'; import 'package:flutter/material.dart'; +import 'package:daily_diary/main.dart'; + import 'package:path_provider/path_provider.dart'; -import 'package:shared_preferences/shared_preferences.dart'; +import 'package:shared_storage/saf.dart'; + +class SavePath { + // Due to the constructors only one can ever be null at any time + const SavePath.normal(String this.path) : uri = null; + const SavePath.android(Uri this.uri) : path = null; + + final String? path; + final Uri? uri; + + bool get isScopedStorage => path == null; + + String get string { + if (isScopedStorage) { + String fullString = Uri.decodeFull(uri!.path); + return fullString.split(':').last; + } else { + return path!.replaceFirst('/storage/emulated/0/', ''); + } + } + + Future getScopedFile(String filename) async { + final file = await getChildFile(filename); + final content = await file.getContentAsString(); + return content!; + } + + void writeScopedFile(String filename, String content) async { + final file = await getChildFile(filename); + file.writeToFileAsString(content: content); + } -Future getPath() async { + Future scopedExists(String filename) async { + final scopedStorageFile = await getChildFile(filename); + final exists = await scopedStorageFile.exists(); + return exists!; + } + + void deleteScoped(String filename) async { + final file = await getChildFile(filename); + file.delete(); + } + + Future getChildFile(String filename) async { + final file = await findFile(uri!, filename); + if (file != null) { + return file; + } + DocumentFile? createdFile = + await createFile(uri!, mimeType: 'text/plain', displayName: filename); + return createdFile!; + } +} + +Future getPath() async { WidgetsFlutterBinding.ensureInitialized(); - final preferences = await SharedPreferences.getInstance(); + final preferences = await App.preferences; String? path = preferences.getString('save_path'); + bool? isAndroidScoped = preferences.getBool('is_android_scoped'); if (path != null) { - return path; + if (isAndroidScoped == true) { + DocumentFile document = DocumentFile.fromMap(json.decode(path)); + return SavePath.android(document.uri); + } + return SavePath.normal(path); } else { - path = await defaultPath; - preferences.setString('save_path', path); - return path; + return resetPathToDefault(); } } -Future get defaultPath async { +/// Resets the savePath to default in `SharedPreferences` and returns the +/// `SavePath` it was set to. It does NOT set the global `savePath`. +/// +/// To set `savePath` do: +/// ``` +/// savePath = await resetPathToDefault(); +/// ``` +Future resetPathToDefault() async { + final preferences = await App.preferences; + String path = await _defaultPath; + preferences.setString('save_path', path); + preferences.setBool('is_android_scoped', false); + return SavePath.normal(path); +} + +Future get _defaultPath async { final directory = await _directory; return directory.path; } diff --git a/lib/screens/previous_entries.dart b/lib/screens/previous_entries.dart index 7e5e878..78d8660 100644 --- a/lib/screens/previous_entries.dart +++ b/lib/screens/previous_entries.dart @@ -53,7 +53,8 @@ class PreviousEntry extends StatelessWidget { context, MaterialPageRoute( builder: (context) { - String filename = date.toIso8601String().substring(0, 10); + String isoDate = date.toIso8601String().substring(0, 10); + String filename = '$isoDate.txt'; final storage = PreviousEntryStorage(filename, savePath!); return ViewOnlyScreen(title: humanDate, storage: storage); }, diff --git a/lib/screens/settings.dart b/lib/screens/settings.dart index 7b8f733..29b41f0 100644 --- a/lib/screens/settings.dart +++ b/lib/screens/settings.dart @@ -1,13 +1,14 @@ +import 'dart:convert'; import 'dart:io'; import 'package:flutter/material.dart'; -import 'package:file_picker/file_picker.dart'; -import 'package:flutter_colorpicker/flutter_colorpicker.dart'; -import 'package:shared_preferences/shared_preferences.dart'; - import 'package:daily_diary/main.dart'; import 'package:daily_diary/path.dart'; +import 'package:file_picker/file_picker.dart'; +import 'package:flutter_colorpicker/flutter_colorpicker.dart'; +import 'package:shared_storage/shared_storage.dart' as saf; + class SettingsScreen extends StatelessWidget { const SettingsScreen({super.key}); @@ -338,51 +339,84 @@ class SavePathSetting extends StatefulWidget implements SettingTile { @override Future newDefault() async { - await setSavePath(); + savePath = await resetPathToDefault(); return const SavePathSetting(); } - Future setSavePath() async { - savePath = await defaultPath; - final preferences = await SharedPreferences.getInstance(); - preferences.setString('save_path', await defaultPath); - } - @override State createState() => _SavePathSettingState(); } class _SavePathSettingState extends State { - _selectNewPath() async { - // Load SharedPreferences while user is picking a path - final preferencesFuture = SharedPreferences.getInstance(); - final path = await FilePicker.platform.getDirectoryPath(); - final preferences = await preferencesFuture; + void _selectNewPath() async { + final path = await _askForPath(); if (path == null) { - // if the user aborted the dialog or if the folder path couldn't be resolved. + // The user aborted the dialog or the folder path couldn't be resolved. return; } - preferences.setString('save_path', path); + setState(() { savePath = path; }); } + Future _askForPath() async { + if (Platform.isAndroid) { + return _askForPathAndroid(); + } + String? path = await FilePicker.platform.getDirectoryPath(); + if (path == null) { + return null; + } + + final preferences = await App.preferences; + preferences.setString('save_path', path); + preferences.setBool('is_android_scoped', false); + return SavePath.normal(path); + } + + Future _askForPathAndroid() async { + _removePreviousPermissions(); + + // Ask user for path and permissions + Uri? uri; + while (uri == null) { + uri = await saf.openDocumentTree(); + } + + // Only null before Android API 21, but this project is API 21+ + final asDocumentFile = await uri.toDocumentFile(); + Map asMap = asDocumentFile!.toMap(); + String asString = json.encode(asMap); + final preferences = await App.preferences; + preferences.setString('save_path', asString); + preferences.setBool('is_android_scoped', true); + return SavePath.android(uri); + } + + Future _removePreviousPermissions() async { + SavePath? path = savePath; + if (path == null) return; + + Uri? previousPath = path.uri; + if (previousPath == null) return; + + await saf.releasePersistableUriPermission(previousPath); + } + @override Widget build(BuildContext context) { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Visibility( - visible: Platform.isAndroid, - child: Text( - 'Changing this setting will only work properly if the device is rooted:', - style: TextStyle(color: Theme.of(context).colorScheme.primary), - )), + Text( + 'Save Location:', + style: Theme.of(context).textTheme.titleMedium, + ), ConstrainedBox( constraints: const BoxConstraints(maxWidth: 400), child: TextField( - controller: TextEditingController(text: savePath), + controller: TextEditingController(text: savePath!.string), enabled: false, style: Theme.of(context).textTheme.bodyMedium, ), diff --git a/lib/settings_notifier.dart b/lib/settings_notifier.dart index e1dc77e..a75d50d 100644 --- a/lib/settings_notifier.dart +++ b/lib/settings_notifier.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; +import 'package:daily_diary/path.dart'; import 'package:daily_diary/storage.dart'; class Settings { @@ -10,7 +11,7 @@ class Settings { } class SettingsNotifier extends ValueNotifier { - SettingsNotifier(String savePath) + SettingsNotifier(SavePath savePath) : storage = SettingsStorage(savePath), super(Settings()); diff --git a/lib/storage.dart b/lib/storage.dart index dade813..36257ac 100644 --- a/lib/storage.dart +++ b/lib/storage.dart @@ -2,29 +2,48 @@ import 'dart:async'; import 'dart:io'; import 'package:flutter/material.dart'; +import 'package:daily_diary/path.dart'; + import 'package:flutter_colorpicker/flutter_colorpicker.dart'; +import 'package:shared_storage/saf.dart'; import 'package:toml/toml.dart'; class DiaryStorage { DiaryStorage(this.path); - final String path; + final SavePath path; DateTime date = DateTime.now(); + String get isoDate => date.toIso8601String().substring(0, 10); + File get file { - String isoDate = date.toIso8601String().substring(0, 10); - return File('$path/$isoDate.txt'); + return File('${path.path}/$isoDate.txt'); } Future readFile() async { try { + if (path.isScopedStorage) { + return path.getScopedFile('$isoDate.txt'); + } return await file.readAsString(); } catch (error) { return ''; } } - void writeFile(String text) { + void writeFile(String text) async { + if (path.isScopedStorage) { + if (text.isNotEmpty) { + path.writeScopedFile('$isoDate.txt', text); + return; + } + if (await path.scopedExists('$isoDate.txt')) { + path.deleteScoped('$isoDate.txt'); + return; + } + return; + } + if (text.isNotEmpty) { file.writeAsStringSync(text); return; @@ -43,12 +62,12 @@ class DiaryStorage { class SettingsStorage { SettingsStorage(this.path); - final String path; + final SavePath path; late var settingsMap = _getMap(); Future> _getMap() async { try { - final file = await _document; + TomlDocument file = await _document; return file.toMap(); } on FileSystemException { return {}; @@ -56,10 +75,14 @@ class SettingsStorage { } String get _file { - return '$path/config.toml'; + return '${path.path}/config.toml'; } Future get _document async { + if (path.isScopedStorage) { + String content = await path.getScopedFile('config.toml'); + return TomlDocument.parse(content); + } return TomlDocument.load(_file); } @@ -134,28 +157,57 @@ class SettingsStorage { map[key] = value; settingsMap = Future(() => map); - TomlDocument asToml = TomlDocument.fromMap(map); - await File(_file).writeAsString(asToml.toString()); + //TODO + String asToml = TomlDocument.fromMap(map).toString(); + + if (path.isScopedStorage) { + DocumentFile file = await path.getChildFile('config.toml'); + await file.writeToFileAsString(content: asToml); + } else { + await File(_file).writeAsString(asToml); + } } } class PreviousEntriesStorage { const PreviousEntriesStorage(this.path); - final String path; + final SavePath path; Future> getFiles() async { - final directory = Directory(path); + if (path.isScopedStorage) { + return _getFilesScopedStorage(path.uri!); + } + + final directory = Directory(path.path!); final files = directory.list(); - final filesAsDateTime = files.map(toFilename); + final filesAsDateTime = files.map(toFilenameFromFileEntity); final filesWithoutNull = filesAsDateTime.where((s) => s != null).cast(); final list = await filesWithoutNull.toList(); return list.reversed.toList(); } - DateTime? toFilename(FileSystemEntity file) { - String path = file.path; + Future> _getFilesScopedStorage(Uri uri) async { + if (await canRead(uri) == true) { + //TODO handle lack of permissions + } + final files = listFiles(uri, columns: [DocumentFileColumn.displayName]); + final filesAsDateTime = files.map(toFilenameFromDocumentFile); + final filesWithoutNull = + filesAsDateTime.where((s) => s != null).cast(); + return filesWithoutNull.toList(); + } + + DateTime? toFilenameFromFileEntity(FileSystemEntity file) { + return toFilename(file.path); + } + + DateTime? toFilenameFromDocumentFile(DocumentFile file) { + return toFilename(file.name!); + } + + DateTime? toFilename(String path) { int filenameStart = path.lastIndexOf('/') + 1; int filenameEnd = path.length - 4; String isoDate = path.substring(filenameStart, filenameEnd); @@ -172,15 +224,24 @@ class PreviousEntryStorage { const PreviousEntryStorage(this.filename, this.path); final String filename; - final String path; + final SavePath path; Future readFile() async { try { - final file = File('$path/$filename.txt'); + if (path.isScopedStorage) { + return await _readFileAndroid(); + } + final file = File('${path.path}/$filename'); final contents = await file.readAsString(); return contents; } catch (error) { return ""; } } + + Future _readFileAndroid() async { + DocumentFile? child = await path.getChildFile(filename); + String? contents = await child.getContentAsString(); + return contents!; + } } diff --git a/metadata/en-GB/changelogs/9.txt b/metadata/en-GB/changelogs/9.txt new file mode 100644 index 0000000..441fa91 --- /dev/null +++ b/metadata/en-GB/changelogs/9.txt @@ -0,0 +1 @@ +Allow changing directory on Android without root \ No newline at end of file diff --git a/pubspec.lock b/pubspec.lock index b29deef..eac776c 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -77,10 +77,10 @@ packages: dependency: "direct main" description: name: file_picker - sha256: e6c7ad8e572379df86ea64ef0a5395889fba3954411d47ca021b888d79f8e798 + sha256: b85eb92b175767fdaa0c543bf3b0d1f610fe966412ea72845fe5ba7801e763ff url: "https://pub.dev" source: hosted - version: "5.2.11" + version: "5.2.10" flutter: dependency: "direct main" description: flutter @@ -196,10 +196,10 @@ packages: dependency: transitive description: name: path_provider_android - sha256: "2cec049d282c7f13c594b4a73976b0b4f2d7a1838a6dd5aaf7bd9719196bee86" + sha256: da97262be945a72270513700a92b39dd2f4a54dad55d061687e2e37a6390366a url: "https://pub.dev" source: hosted - version: "2.0.27" + version: "2.0.25" path_provider_foundation: dependency: transitive description: @@ -276,10 +276,10 @@ packages: dependency: transitive description: name: shared_preferences_android - sha256: "6478c6bbbecfe9aced34c483171e90d7c078f5883558b30ec3163cf18402c749" + sha256: "7fa90471a6875d26ad78c7e4a675874b2043874586891128dc5899662c97db46" url: "https://pub.dev" source: hosted - version: "2.1.4" + version: "2.1.2" shared_preferences_foundation: dependency: transitive description: @@ -320,6 +320,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.2.0" + shared_storage: + dependency: "direct main" + description: + name: shared_storage + sha256: "2761c1f6562d7cfb7626982d3fc7ce0bb1d9bf86ff7db2f6d77ee078d8f57de9" + url: "https://pub.dev" + source: hosted + version: "0.7.1" sky_engine: dependency: transitive description: flutter @@ -393,10 +401,10 @@ packages: dependency: transitive description: name: win32 - sha256: dd8f9344bc305ae2923e3d11a2a911d9a4e2c7dd6fe0ed10626d63211a69676e + sha256: a6f0236dbda0f63aa9a25ad1ff9a9d8a4eaaa5012da0dc59d21afdb1dc361ca4 url: "https://pub.dev" source: hosted - version: "4.1.3" + version: "3.1.4" xdg_directories: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index a3a1564..a4c00d0 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -4,7 +4,7 @@ description: A diary that starts with a blank page every day (while saving the p # pub.dev using `flutter pub publish`. This is preferred for private packages. publish_to: 'none' # Remove this line if you wish to publish to pub.dev -version: 1.2.4+8 +version: 1.2.5+9 environment: sdk: '>=2.19.0-374.1.beta <3.0.0' @@ -24,6 +24,7 @@ dependencies: shared_preferences: ^2.0.15 file_picker: ^5.2.4 flutter_window_close: ^0.2.2 + shared_storage: ^0.7.1 dev_dependencies: flutter_test: diff --git a/test/storage/diary_storage_test.dart b/test/storage/diary_storage_test.dart index 8e56d3f..5cd0387 100644 --- a/test/storage/diary_storage_test.dart +++ b/test/storage/diary_storage_test.dart @@ -1,5 +1,6 @@ import 'dart:io'; +import 'package:daily_diary/path.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:daily_diary/storage.dart'; @@ -12,7 +13,7 @@ main() { }); test('Normal', () async { - final storage = DiaryStorage('test_data/'); + final storage = DiaryStorage(const SavePath.normal('test_data/')); const testText = 'This is a test diary\n a newline here\nwow, another'; storage.writeFile(testText); String result = await storage.readFile(); @@ -21,7 +22,7 @@ main() { }); test('Unicode', () async { - final storage = DiaryStorage('test_data/'); + final storage = DiaryStorage(const SavePath.normal('test_data/')); const testText = 'This is a اختبر diary\n a newline here\nа вот еще один'; storage.writeFile(testText); String result = await storage.readFile(); diff --git a/test/storage/settings_storage_test.dart b/test/storage/settings_storage_test.dart index f20da83..6a8bbd1 100644 --- a/test/storage/settings_storage_test.dart +++ b/test/storage/settings_storage_test.dart @@ -3,6 +3,7 @@ import 'dart:io'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:daily_diary/path.dart'; import 'package:daily_diary/storage.dart'; main() { @@ -13,7 +14,7 @@ main() { }); test('Normal', () async { - final storage = SettingsStorage(testDirectory.path); + final storage = SettingsStorage(SavePath.normal(testDirectory.path)); await storage.setTheme(ThemeMode.dark); await storage.setFontSize(42); diff --git a/test/widget_test.dart b/test/widget_test.dart index 01340a0..9c1e0ac 100644 --- a/test/widget_test.dart +++ b/test/widget_test.dart @@ -2,10 +2,11 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:daily_diary/main.dart'; +import 'package:daily_diary/path.dart'; import 'package:daily_diary/screens/settings.dart'; main() { - savePath = ""; + savePath = const SavePath.normal(''); testWidgets('Navigation', (WidgetTester tester) async { await tester.pumpWidget(const App());