/* * 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 'package:stackwallet/db/isar/main_db.dart'; import 'package:stackwallet/models/isar/stack_theme.dart'; import 'package:stackwallet/networking/http.dart'; import 'package:stackwallet/services/tor_service.dart'; import 'package:stackwallet/utilities/logger.dart'; import 'package:stackwallet/utilities/prefs.dart'; import 'package:stackwallet/utilities/stack_file_system.dart'; final pThemeService = Provider((ref) { return ThemeService.instance; }); class ThemeService { static const _currentDefaultThemeVersion = 10; 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 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); final json = jsonDecode(jsonString) as Map; final theme = StackTheme.fromJson( json: Map.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 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 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 // 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, ); // } } } // TODO more thorough check/verification of theme Future 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> 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>.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 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 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 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" ")"; } }