2023-05-26 21:21:16 +00:00
|
|
|
/*
|
|
|
|
* This file is part of Stack Wallet.
|
|
|
|
*
|
|
|
|
* Copyright (c) 2023 Cypher Stack
|
|
|
|
* All Rights Reserved.
|
|
|
|
* The code is distributed under GPLv3 license, see LICENSE file for details.
|
|
|
|
* Generated by Cypher Stack on 2023-05-26
|
|
|
|
*
|
|
|
|
*/
|
|
|
|
|
2023-05-09 23:18:13 +00:00
|
|
|
import 'dart:convert';
|
|
|
|
import 'dart:io';
|
2023-05-09 21:57:40 +00:00
|
|
|
|
2023-05-09 23:18:13 +00:00
|
|
|
import 'package:archive/archive_io.dart';
|
2023-05-10 19:21:08 +00:00
|
|
|
import 'package:crypto/crypto.dart';
|
2023-05-18 22:45:31 +00:00
|
|
|
import 'package:flutter/services.dart';
|
2023-05-09 21:57:40 +00:00
|
|
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
2023-05-10 19:02:35 +00:00
|
|
|
import 'package:http/http.dart';
|
2023-05-09 21:57:40 +00:00
|
|
|
import 'package:isar/isar.dart';
|
|
|
|
import 'package:stackwallet/db/isar/main_db.dart';
|
|
|
|
import 'package:stackwallet/models/isar/stack_theme.dart';
|
2023-05-09 23:18:13 +00:00
|
|
|
import 'package:stackwallet/utilities/logger.dart';
|
|
|
|
import 'package:stackwallet/utilities/stack_file_system.dart';
|
2023-05-09 21:57:40 +00:00
|
|
|
|
|
|
|
final pThemeService = Provider<ThemeService>((ref) {
|
|
|
|
return ThemeService.instance;
|
|
|
|
});
|
|
|
|
|
|
|
|
class ThemeService {
|
2023-06-05 21:46:32 +00:00
|
|
|
static const _currentDefaultThemeVersion = 3;
|
2023-05-09 21:57:40 +00:00
|
|
|
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";
|
|
|
|
|
2023-05-09 21:57:40 +00:00
|
|
|
MainDB? _db;
|
|
|
|
MainDB get db => _db!;
|
|
|
|
|
|
|
|
void init(MainDB db) => _db ??= db;
|
|
|
|
|
2023-05-12 19:51:15 +00:00
|
|
|
Future<void> install({required Uint8List themeArchiveData}) async {
|
2023-07-04 00:25:18 +00:00
|
|
|
final themesDir = StackFileSystem.themesDir!;
|
2023-05-09 23:18:13 +00:00
|
|
|
|
2023-05-12 19:51:15 +00:00
|
|
|
final archive = ZipDecoder().decodeBytes(themeArchiveData);
|
2023-05-09 23:18:13 +00:00
|
|
|
|
|
|
|
final themeJsonFiles = archive.files.where((e) => e.name == "theme.json");
|
|
|
|
|
|
|
|
if (themeJsonFiles.length != 1) {
|
|
|
|
throw Exception("Invalid theme archive: Missing theme.json");
|
|
|
|
}
|
|
|
|
|
2023-06-05 16:07:50 +00:00
|
|
|
final jsonString = utf8.decode(themeJsonFiles.first.content as List<int>);
|
2023-05-09 23:18:13 +00:00
|
|
|
final json = jsonDecode(jsonString) as Map;
|
|
|
|
|
|
|
|
final theme = StackTheme.fromJson(
|
|
|
|
json: Map<String, dynamic>.from(json),
|
|
|
|
);
|
|
|
|
|
2023-06-07 14:36:47 +00:00
|
|
|
try {
|
|
|
|
theme.assets;
|
|
|
|
} catch (_) {
|
|
|
|
throw Exception("Invalid theme: Failed to create assets object");
|
|
|
|
}
|
|
|
|
|
2023-05-09 23:18:13 +00:00
|
|
|
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);
|
|
|
|
});
|
2023-05-09 21:57:40 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
Future<void> remove({required String themeId}) async {
|
2023-07-04 00:25:18 +00:00
|
|
|
final themesDir = StackFileSystem.themesDir!;
|
2023-05-09 23:18:13 +00:00
|
|
|
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);
|
|
|
|
});
|
2023-06-09 18:44:47 +00:00
|
|
|
final dir = Directory("${themesDir.path}/$themeId");
|
|
|
|
if (dir.existsSync()) {
|
|
|
|
await dir.delete(recursive: true);
|
|
|
|
}
|
2023-05-09 23:18:13 +00:00
|
|
|
} else {
|
|
|
|
Logging.instance.log(
|
|
|
|
"Failed to delete theme $themeId",
|
|
|
|
level: LogLevel.Warning,
|
|
|
|
);
|
|
|
|
}
|
2023-05-09 21:57:40 +00:00
|
|
|
}
|
|
|
|
|
2023-05-18 22:45:31 +00:00
|
|
|
Future<void> checkDefaultThemesOnStartup() async {
|
|
|
|
// install default themes
|
|
|
|
if (!(await ThemeService.instance.verifyInstalled(themeId: "light"))) {
|
|
|
|
Logging.instance.log(
|
|
|
|
"Installing default light theme...",
|
|
|
|
level: LogLevel.Info,
|
|
|
|
);
|
|
|
|
final lightZip = await rootBundle.load("assets/default_themes/light.zip");
|
|
|
|
await ThemeService.instance
|
|
|
|
.install(themeArchiveData: lightZip.buffer.asUint8List());
|
|
|
|
Logging.instance.log(
|
|
|
|
"Installing default light theme... finished",
|
|
|
|
level: LogLevel.Info,
|
|
|
|
);
|
|
|
|
} else {
|
|
|
|
// check installed version
|
|
|
|
final theme = ThemeService.instance.getTheme(themeId: "light");
|
|
|
|
if ((theme?.version ?? 1) < _currentDefaultThemeVersion) {
|
|
|
|
Logging.instance.log(
|
|
|
|
"Updating default light theme...",
|
|
|
|
level: LogLevel.Info,
|
|
|
|
);
|
|
|
|
final lightZip =
|
|
|
|
await rootBundle.load("assets/default_themes/light.zip");
|
|
|
|
await ThemeService.instance
|
|
|
|
.install(themeArchiveData: lightZip.buffer.asUint8List());
|
|
|
|
Logging.instance.log(
|
|
|
|
"Updating default light theme... finished",
|
|
|
|
level: LogLevel.Info,
|
|
|
|
);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
if (!(await ThemeService.instance.verifyInstalled(themeId: "dark"))) {
|
|
|
|
Logging.instance.log(
|
|
|
|
"Installing default dark theme... ",
|
|
|
|
level: LogLevel.Info,
|
|
|
|
);
|
|
|
|
final darkZip = await rootBundle.load("assets/default_themes/dark.zip");
|
|
|
|
await ThemeService.instance
|
|
|
|
.install(themeArchiveData: darkZip.buffer.asUint8List());
|
|
|
|
Logging.instance.log(
|
|
|
|
"Installing default dark theme... finished",
|
|
|
|
level: LogLevel.Info,
|
|
|
|
);
|
|
|
|
} else {
|
|
|
|
// check installed version
|
2023-07-01 21:15:22 +00:00
|
|
|
// final theme = ThemeService.instance.getTheme(themeId: "dark");
|
|
|
|
// Force update theme to add missing icons for now
|
|
|
|
// TODO: uncomment if statement in future when themes are version 4 or above
|
|
|
|
// if ((theme?.version ?? 1) < _currentDefaultThemeVersion) {
|
|
|
|
Logging.instance.log(
|
|
|
|
"Updating default dark theme...",
|
|
|
|
level: LogLevel.Info,
|
|
|
|
);
|
|
|
|
final darkZip = await rootBundle.load("assets/default_themes/dark.zip");
|
|
|
|
await ThemeService.instance
|
|
|
|
.install(themeArchiveData: darkZip.buffer.asUint8List());
|
|
|
|
Logging.instance.log(
|
|
|
|
"Updating default dark theme... finished",
|
|
|
|
level: LogLevel.Info,
|
|
|
|
);
|
|
|
|
// }
|
2023-05-18 22:45:31 +00:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2023-05-10 14:53:46 +00:00
|
|
|
// TODO more thorough check/verification of theme
|
|
|
|
Future<bool> verifyInstalled({required String themeId}) async {
|
2023-06-07 14:36:47 +00:00
|
|
|
final theme =
|
|
|
|
await db.isar.stackThemes.where().themeIdEqualTo(themeId).findFirst();
|
|
|
|
if (theme != null) {
|
|
|
|
try {
|
|
|
|
theme.assets;
|
|
|
|
} catch (_) {
|
|
|
|
return false;
|
|
|
|
}
|
|
|
|
|
2023-07-04 00:25:18 +00:00
|
|
|
final themesDir = StackFileSystem.themesDir!;
|
2023-05-10 14:53:46 +00:00
|
|
|
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 22:49:56 +00:00
|
|
|
Future<List<StackThemeMetaData>> fetchThemes() async {
|
2023-05-10 19:02:35 +00:00
|
|
|
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))
|
2023-05-10 22:49:56 +00:00
|
|
|
.where((e) => e.id != "light" && e.id != "dark")
|
2023-05-10 19:02:35 +00:00
|
|
|
.toList();
|
|
|
|
|
|
|
|
return result;
|
|
|
|
} catch (e, s) {
|
|
|
|
Logging.instance.log(
|
|
|
|
"Failed to fetch themes list: $e\n$s",
|
|
|
|
level: LogLevel.Warning,
|
|
|
|
);
|
|
|
|
rethrow;
|
|
|
|
}
|
2023-05-09 21:57:40 +00:00
|
|
|
}
|
|
|
|
|
2023-05-12 19:51:15 +00:00
|
|
|
Future<Uint8List> fetchTheme({
|
2023-05-10 19:21:08 +00:00
|
|
|
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) {
|
2023-05-12 19:51:15 +00:00
|
|
|
return bytes;
|
2023-05-10 19:21:08 +00:00
|
|
|
} 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;
|
|
|
|
}
|
2023-05-09 21:57:40 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
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-18 22:45:31 +00:00
|
|
|
final int version;
|
2023-05-10 19:21:08 +00:00
|
|
|
final String sha256;
|
2023-05-10 22:49:56 +00:00
|
|
|
final String size;
|
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-18 22:45:31 +00:00
|
|
|
required this.version,
|
2023-05-10 19:21:08 +00:00
|
|
|
required this.sha256,
|
2023-05-10 22:49:56 +00:00
|
|
|
required this.size,
|
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-18 22:45:31 +00:00
|
|
|
version: map["version"] as int? ?? 1,
|
2023-05-10 19:21:08 +00:00
|
|
|
sha256: map["sha256"] as String,
|
2023-05-10 22:49:56 +00:00
|
|
|
size: map["size"] 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, "
|
2023-05-18 22:45:31 +00:00
|
|
|
"version: $version, "
|
2023-05-10 19:25:17 +00:00
|
|
|
"sha256: $sha256, "
|
2023-05-10 22:49:56 +00:00
|
|
|
"size: $size, "
|
2023-05-10 19:25:17 +00:00
|
|
|
"previewImageUrl: $previewImageUrl"
|
|
|
|
")";
|
2023-05-10 19:02:35 +00:00
|
|
|
}
|
|
|
|
}
|