stack_wallet/lib/themes/theme_service.dart

217 lines
6 KiB
Dart
Raw Normal View History

import 'dart:convert';
import 'dart:io';
import 'dart:typed_data';
import 'package:archive/archive_io.dart';
2023-05-10 19:21:08 +00:00
import 'package:crypto/crypto.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
2023-05-10 19:02:35 +00:00
import 'package:http/http.dart';
import 'package:isar/isar.dart';
import 'package:stackwallet/db/isar/main_db.dart';
import 'package:stackwallet/models/isar/stack_theme.dart';
import 'package:stackwallet/utilities/logger.dart';
import 'package:stackwallet/utilities/stack_file_system.dart';
final pThemeService = Provider<ThemeService>((ref) {
return ThemeService.instance;
});
class ThemeService {
ThemeService._();
static ThemeService? _instance;
static ThemeService get instance => _instance ??= ThemeService._();
2023-05-10 19:02:35 +00:00
static const String baseServerUrl = "https://themes.stackwallet.com";
MainDB? _db;
MainDB get db => _db!;
void init(MainDB db) => _db ??= db;
Future<void> install({required ByteData themeArchive}) async {
final themesDir = await StackFileSystem.applicationThemesDirectory();
final byteStream = InputStream(themeArchive);
final archive = ZipDecoder().decodeBuffer(byteStream);
final themeJsonFiles = archive.files.where((e) => e.name == "theme.json");
if (themeJsonFiles.length != 1) {
throw Exception("Invalid theme archive: Missing theme.json");
}
final OutputStream os = OutputStream();
themeJsonFiles.first.decompress(os);
final String jsonString = utf8.decode(os.getBytes());
final json = jsonDecode(jsonString) as Map;
final theme = StackTheme.fromJson(
json: Map<String, dynamic>.from(json),
applicationThemesDirectoryPath: themesDir.path,
);
final String assetsPath = "${themesDir.path}/${theme.themeId}";
for (final file in archive.files) {
if (file.isFile) {
// TODO more sanitation?
if (file.name.contains("..")) {
Logging.instance.log(
"Bad theme asset file path: ${file.name}",
level: LogLevel.Error,
);
} else {
final os = OutputFileStream("$assetsPath/${file.name}");
file.writeContent(os);
await os.close();
}
}
}
await db.isar.writeTxn(() async {
await db.isar.stackThemes.put(theme);
});
}
Future<void> remove({required String themeId}) async {
final themesDir = await StackFileSystem.applicationThemesDirectory();
final isarId = await db.isar.stackThemes
.where()
.themeIdEqualTo(themeId)
.idProperty()
.findFirst();
if (isarId != null) {
await db.isar.writeTxn(() async {
await db.isar.stackThemes.delete(isarId);
});
await Directory("${themesDir.path}/$themeId").delete(recursive: true);
} else {
Logging.instance.log(
"Failed to delete theme $themeId",
level: LogLevel.Warning,
);
}
}
2023-05-10 14:53:46 +00:00
// TODO more thorough check/verification of theme
Future<bool> verifyInstalled({required String themeId}) async {
final dbHasTheme =
await db.isar.stackThemes.where().themeIdEqualTo(themeId).count() > 0;
if (dbHasTheme) {
final themesDir = await StackFileSystem.applicationThemesDirectory();
final jsonFileExists =
await File("${themesDir.path}/$themeId/theme.json").exists();
final assetsDirExists =
await Directory("${themesDir.path}/$themeId/assets").exists();
if (!jsonFileExists || !assetsDirExists) {
Logging.instance.log(
"Theme $themeId found in DB but is missing files",
level: LogLevel.Warning,
);
}
return jsonFileExists && assetsDirExists;
} else {
return false;
}
}
2023-05-10 19:02:35 +00:00
Future<List<StackThemeMetaData>> fetchThemeList() async {
try {
final response = await get(Uri.parse("$baseServerUrl/themes"));
final jsonList = jsonDecode(response.body) as List;
final result = List<Map<String, dynamic>>.from(jsonList)
.map((e) => StackThemeMetaData.fromMap(e))
.toList();
return result;
} catch (e, s) {
Logging.instance.log(
"Failed to fetch themes list: $e\n$s",
level: LogLevel.Warning,
);
rethrow;
}
}
2023-05-10 19:21:08 +00:00
Future<ByteData> fetchTheme({
required StackThemeMetaData themeMetaData,
}) async {
2023-05-10 19:02:35 +00:00
try {
2023-05-10 19:21:08 +00:00
final response =
await get(Uri.parse("$baseServerUrl/theme/${themeMetaData.id}"));
2023-05-10 19:02:35 +00:00
final bytes = response.bodyBytes;
2023-05-10 19:21:08 +00:00
// verify hash
final digest = sha256.convert(bytes);
if (digest.toString() == themeMetaData.sha256) {
final result = ByteData.view(bytes.buffer);
2023-05-10 19:02:35 +00:00
2023-05-10 19:21:08 +00:00
return result;
} else {
throw Exception(
"Fetched theme archive sha256 hash ($digest) does not"
" match requested $themeMetaData",
);
}
2023-05-10 19:02:35 +00:00
} catch (e, s) {
Logging.instance.log(
"Failed to fetch themes list: $e\n$s",
level: LogLevel.Warning,
);
rethrow;
}
}
StackTheme? getTheme({required String themeId}) =>
db.isar.stackThemes.where().themeIdEqualTo(themeId).findFirstSync();
List<StackTheme> get installedThemes =>
db.isar.stackThemes.where().findAllSync();
}
2023-05-10 19:02:35 +00:00
class StackThemeMetaData {
final String name;
final String id;
2023-05-10 19:21:08 +00:00
final String sha256;
2023-05-10 19:25:17 +00:00
final String previewImageUrl;
2023-05-10 19:02:35 +00:00
StackThemeMetaData({
required this.name,
required this.id,
2023-05-10 19:21:08 +00:00
required this.sha256,
2023-05-10 19:25:17 +00:00
required this.previewImageUrl,
2023-05-10 19:02:35 +00:00
});
static StackThemeMetaData fromMap(Map<String, dynamic> map) {
try {
return StackThemeMetaData(
name: map["name"] as String,
id: map["id"] as String,
2023-05-10 19:21:08 +00:00
sha256: map["sha256"] as String,
2023-05-10 19:25:17 +00:00
previewImageUrl: map["previewImageUrl"] as String,
2023-05-10 19:02:35 +00:00
);
} catch (e, s) {
Logging.instance.log(
"Failed to create instance of StackThemeMetaData using $map: \n$e\n$s",
level: LogLevel.Fatal,
);
rethrow;
}
}
@override
String toString() {
2023-05-10 19:25:17 +00:00
return "$runtimeType("
"name: $name, "
"id: $id, "
"sha256: $sha256, "
"previewImageUrl: $previewImageUrl"
")";
2023-05-10 19:02:35 +00:00
}
}