stack_wallet/lib/themes/theme_service.dart

301 lines
8.3 KiB
Dart

/*
* 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
*
*/
import 'dart:convert';
import 'dart:io';
import 'package:archive/archive_io.dart';
import 'package:crypto/crypto.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:isar/isar.dart';
import '../app_config.dart';
import '../db/isar/main_db.dart';
import '../models/isar/stack_theme.dart';
import '../networking/http.dart';
import '../services/tor_service.dart';
import '../utilities/logger.dart';
import '../utilities/prefs.dart';
import '../utilities/stack_file_system.dart';
final pThemeService = Provider<ThemeService>((ref) {
return ThemeService.instance;
});
class ThemeService {
// dumb quick conditional based on name. Should really be done better
static const _currentDefaultThemeVersion =
AppConfig.appName == "Campfire" ? 17 : 16;
ThemeService._();
static ThemeService? _instance;
static ThemeService get instance => _instance ??= ThemeService._();
static const String baseServerUrl = "https://themes.stackwallet.com";
MainDB? _db;
MainDB get db => _db!;
void init(MainDB db) => _db ??= db;
HTTP client = HTTP();
Future<void> install({required Uint8List themeArchiveData}) async {
final themesDir = StackFileSystem.themesDir!;
final archive = ZipDecoder().decodeBytes(themeArchiveData);
final themeJsonFiles = archive.files.where((e) => e.name == "theme.json");
if (themeJsonFiles.length != 1) {
throw Exception("Invalid theme archive: Missing theme.json");
}
final jsonString = utf8.decode(themeJsonFiles.first.content as List<int>);
final json = jsonDecode(jsonString) as Map;
final theme = StackTheme.fromJson(
json: Map<String, dynamic>.from(json),
);
try {
theme.assets;
} catch (_) {
throw Exception("Invalid theme: Failed to create assets object");
}
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 = StackFileSystem.themesDir!;
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);
});
final dir = Directory("${themesDir.path}/$themeId");
if (dir.existsSync()) {
await dir.delete(recursive: true);
}
} else {
Logging.instance.log(
"Failed to delete theme $themeId",
level: LogLevel.Warning,
);
}
}
Future<void> checkDefaultThemesOnStartup() async {
// install default themes
if (!(await ThemeService.instance.verifyInstalled(themeId: "light"))) {
await _updateDefaultTheme("light");
} else {
// check installed version
final theme = ThemeService.instance.getTheme(themeId: "light");
if ((theme?.version ?? 1) < _currentDefaultThemeVersion) {
await _updateDefaultTheme("light");
}
}
if (AppConfig.hasFeature(AppFeature.themeSelection)) {
if (!(await ThemeService.instance.verifyInstalled(themeId: "dark"))) {
await _updateDefaultTheme("dark");
} else {
// check installed version
final theme = ThemeService.instance.getTheme(themeId: "dark");
if ((theme?.version ?? 1) < _currentDefaultThemeVersion) {
await _updateDefaultTheme("dark");
}
}
}
}
Future<void> _updateDefaultTheme(String name) async {
Logging.instance.log(
"Updating default $name theme...",
level: LogLevel.Info,
);
final zip = await rootBundle.load("assets/default_themes/$name.zip");
await ThemeService.instance.install(
themeArchiveData: zip.buffer.asUint8List(),
);
Logging.instance.log(
"Updating default $name theme... finished",
level: LogLevel.Info,
);
}
// TODO more thorough check/verification of theme
Future<bool> verifyInstalled({required String themeId}) async {
final theme =
await db.isar.stackThemes.where().themeIdEqualTo(themeId).findFirst();
if (theme != null) {
try {
theme.assets;
} catch (_) {
return false;
}
final themesDir = StackFileSystem.themesDir!;
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;
}
}
Future<List<StackThemeMetaData>> fetchThemes() async {
try {
final response = await client.get(
url: Uri.parse("$baseServerUrl/themes"),
proxyInfo: Prefs.instance.useTor
? TorService.sharedInstance.getProxyInfo()
: null,
);
final jsonList = jsonDecode(response.body) as List;
final result = List<Map<String, dynamic>>.from(jsonList)
.map((e) => StackThemeMetaData.fromMap(e))
.where((e) => e.id != "light" && e.id != "dark")
.toList();
return result;
} catch (e, s) {
Logging.instance.log(
"Failed to fetch themes list: $e\n$s",
level: LogLevel.Warning,
);
rethrow;
}
}
Future<Uint8List> fetchTheme({
required StackThemeMetaData themeMetaData,
}) async {
try {
final response = await client.get(
url: Uri.parse("$baseServerUrl/theme/${themeMetaData.id}"),
proxyInfo: Prefs.instance.useTor
? TorService.sharedInstance.getProxyInfo()
: null,
);
final bytes = Uint8List.fromList(response.bodyBytes);
// verify hash
final digest = sha256.convert(bytes);
if (digest.toString() == themeMetaData.sha256) {
return bytes;
} else {
throw Exception(
"Fetched theme archive sha256 hash ($digest) does not"
" match requested $themeMetaData",
);
}
} 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();
}
class StackThemeMetaData {
final String name;
final String id;
final int version;
final String sha256;
final String size;
final String previewImageUrl;
StackThemeMetaData({
required this.name,
required this.id,
required this.version,
required this.sha256,
required this.size,
required this.previewImageUrl,
});
static StackThemeMetaData fromMap(Map<String, dynamic> map) {
try {
return StackThemeMetaData(
name: map["name"] as String,
id: map["id"] as String,
version: map["version"] as int? ?? 1,
sha256: map["sha256"] as String,
size: map["size"] as String,
previewImageUrl: map["previewImageUrl"] as String,
);
} 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() {
return "$runtimeType("
"name: $name, "
"id: $id, "
"version: $version, "
"sha256: $sha256, "
"size: $size, "
"previewImageUrl: $previewImageUrl"
")";
}
}