mirror of
				https://github.com/ImranR98/Obtainium.git
				synced 2025-11-04 07:13:28 +01:00 
			
		
		
		
	
		
			
				
	
	
		
			2562 lines
		
	
	
		
			84 KiB
		
	
	
	
		
			Dart
		
	
	
	
	
	
			
		
		
	
	
			2562 lines
		
	
	
		
			84 KiB
		
	
	
	
		
			Dart
		
	
	
	
	
	
// Manages state related to the list of Apps tracked by Obtainium,
 | 
						|
// Exposes related functions such as those used to add, remove, download, and install Apps.
 | 
						|
 | 
						|
import 'dart:async';
 | 
						|
import 'dart:convert';
 | 
						|
import 'dart:io';
 | 
						|
import 'dart:math';
 | 
						|
import 'package:battery_plus/battery_plus.dart';
 | 
						|
import 'package:fluttertoast/fluttertoast.dart';
 | 
						|
import 'package:crypto/crypto.dart';
 | 
						|
import 'dart:typed_data';
 | 
						|
 | 
						|
import 'package:android_intent_plus/flag.dart';
 | 
						|
import 'package:android_package_installer/android_package_installer.dart';
 | 
						|
import 'package:android_package_manager/android_package_manager.dart';
 | 
						|
import 'package:connectivity_plus/connectivity_plus.dart';
 | 
						|
import 'package:device_info_plus/device_info_plus.dart';
 | 
						|
import 'package:easy_localization/easy_localization.dart';
 | 
						|
import 'package:flutter/material.dart';
 | 
						|
import 'package:flutter/services.dart';
 | 
						|
import 'package:http/io_client.dart';
 | 
						|
import 'package:obtainium/app_sources/directAPKLink.dart';
 | 
						|
import 'package:obtainium/app_sources/html.dart';
 | 
						|
import 'package:obtainium/components/generated_form.dart';
 | 
						|
import 'package:obtainium/components/generated_form_modal.dart';
 | 
						|
import 'package:obtainium/custom_errors.dart';
 | 
						|
import 'package:obtainium/main.dart';
 | 
						|
import 'package:obtainium/providers/logs_provider.dart';
 | 
						|
import 'package:obtainium/providers/notifications_provider.dart';
 | 
						|
import 'package:obtainium/providers/settings_provider.dart';
 | 
						|
import 'package:permission_handler/permission_handler.dart';
 | 
						|
import 'package:provider/provider.dart';
 | 
						|
import 'package:path_provider/path_provider.dart';
 | 
						|
import 'package:flutter_fgbg/flutter_fgbg.dart';
 | 
						|
import 'package:obtainium/providers/source_provider.dart';
 | 
						|
import 'package:http/http.dart';
 | 
						|
import 'package:android_intent_plus/android_intent.dart';
 | 
						|
import 'package:flutter_archive/flutter_archive.dart';
 | 
						|
import 'package:share_plus/share_plus.dart';
 | 
						|
import 'package:shared_storage/shared_storage.dart' as saf;
 | 
						|
import 'package:shizuku_apk_installer/shizuku_apk_installer.dart';
 | 
						|
 | 
						|
final pm = AndroidPackageManager();
 | 
						|
 | 
						|
class AppInMemory {
 | 
						|
  late App app;
 | 
						|
  double? downloadProgress;
 | 
						|
  PackageInfo? installedInfo;
 | 
						|
  Uint8List? icon;
 | 
						|
 | 
						|
  AppInMemory(this.app, this.downloadProgress, this.installedInfo, this.icon);
 | 
						|
  AppInMemory deepCopy() =>
 | 
						|
      AppInMemory(app.deepCopy(), downloadProgress, installedInfo, icon);
 | 
						|
 | 
						|
  String get name => app.overrideName ?? app.finalName;
 | 
						|
  String get author => app.overrideAuthor ?? app.finalAuthor;
 | 
						|
}
 | 
						|
 | 
						|
class DownloadedApk {
 | 
						|
  String appId;
 | 
						|
  File file;
 | 
						|
  DownloadedApk(this.appId, this.file);
 | 
						|
}
 | 
						|
 | 
						|
class DownloadedXApkDir {
 | 
						|
  String appId;
 | 
						|
  File file;
 | 
						|
  Directory extracted;
 | 
						|
  DownloadedXApkDir(this.appId, this.file, this.extracted);
 | 
						|
}
 | 
						|
 | 
						|
List<String> generateStandardVersionRegExStrings() {
 | 
						|
  var basics = [
 | 
						|
    '[0-9]+',
 | 
						|
    '[0-9]+\\.[0-9]+',
 | 
						|
    '[0-9]+\\.[0-9]+\\.[0-9]+',
 | 
						|
    '[0-9]+\\.[0-9]+\\.[0-9]+\\.[0-9]+',
 | 
						|
  ];
 | 
						|
  var preSuffixes = ['-', '\\+'];
 | 
						|
  var suffixes = ['alpha', 'beta', 'ose', '[0-9]+'];
 | 
						|
  var finals = ['\\+[0-9]+', '[0-9]+'];
 | 
						|
  List<String> results = [];
 | 
						|
  for (var b in basics) {
 | 
						|
    results.add(b);
 | 
						|
    for (var p in preSuffixes) {
 | 
						|
      for (var s in suffixes) {
 | 
						|
        results.add('$b$s');
 | 
						|
        results.add('$b$p$s');
 | 
						|
        for (var f in finals) {
 | 
						|
          results.add('$b$s$f');
 | 
						|
          results.add('$b$p$s$f');
 | 
						|
        }
 | 
						|
      }
 | 
						|
    }
 | 
						|
  }
 | 
						|
  return results;
 | 
						|
}
 | 
						|
 | 
						|
List<String> standardVersionRegExStrings =
 | 
						|
    generateStandardVersionRegExStrings();
 | 
						|
 | 
						|
Set<String> findStandardFormatsForVersion(String version, bool strict) {
 | 
						|
  // If !strict, even a substring match is valid
 | 
						|
  Set<String> results = {};
 | 
						|
  for (var pattern in standardVersionRegExStrings) {
 | 
						|
    if (RegExp(
 | 
						|
      '${strict ? '^' : ''}$pattern${strict ? '\$' : ''}',
 | 
						|
    ).hasMatch(version)) {
 | 
						|
      results.add(pattern);
 | 
						|
    }
 | 
						|
  }
 | 
						|
  return results;
 | 
						|
}
 | 
						|
 | 
						|
List<String> moveStrToEnd(List<String> arr, String str, {String? strB}) {
 | 
						|
  String? temp;
 | 
						|
  arr.removeWhere((element) {
 | 
						|
    bool res = element == str || element == strB;
 | 
						|
    if (res) {
 | 
						|
      temp = element;
 | 
						|
    }
 | 
						|
    return res;
 | 
						|
  });
 | 
						|
  if (temp != null) {
 | 
						|
    arr = [...arr, temp!];
 | 
						|
  }
 | 
						|
  return arr;
 | 
						|
}
 | 
						|
 | 
						|
List<MapEntry<String, int>> moveStrToEndMapEntryWithCount(
 | 
						|
  List<MapEntry<String, int>> arr,
 | 
						|
  MapEntry<String, int> str, {
 | 
						|
  MapEntry<String, int>? strB,
 | 
						|
}) {
 | 
						|
  MapEntry<String, int>? temp;
 | 
						|
  arr.removeWhere((element) {
 | 
						|
    bool resA = element.key == str.key;
 | 
						|
    bool resB = element.key == strB?.key;
 | 
						|
    if (resA) {
 | 
						|
      temp = str;
 | 
						|
    } else if (resB) {
 | 
						|
      temp = strB;
 | 
						|
    }
 | 
						|
    return resA || resB;
 | 
						|
  });
 | 
						|
  if (temp != null) {
 | 
						|
    arr = [...arr, temp!];
 | 
						|
  }
 | 
						|
  return arr;
 | 
						|
}
 | 
						|
 | 
						|
Future<File> downloadFileWithRetry(
 | 
						|
  String url,
 | 
						|
  String fileName,
 | 
						|
  bool fileNameHasExt,
 | 
						|
  Function? onProgress,
 | 
						|
  String destDir, {
 | 
						|
  bool useExisting = true,
 | 
						|
  Map<String, String>? headers,
 | 
						|
  int retries = 3,
 | 
						|
  bool allowInsecure = false,
 | 
						|
  LogsProvider? logs,
 | 
						|
}) async {
 | 
						|
  try {
 | 
						|
    return await downloadFile(
 | 
						|
      url,
 | 
						|
      fileName,
 | 
						|
      fileNameHasExt,
 | 
						|
      onProgress,
 | 
						|
      destDir,
 | 
						|
      useExisting: useExisting,
 | 
						|
      headers: headers,
 | 
						|
      allowInsecure: allowInsecure,
 | 
						|
      logs: logs,
 | 
						|
    );
 | 
						|
  } catch (e) {
 | 
						|
    if (retries > 0 && e is ClientException) {
 | 
						|
      await Future.delayed(const Duration(seconds: 5));
 | 
						|
      return await downloadFileWithRetry(
 | 
						|
        url,
 | 
						|
        fileName,
 | 
						|
        fileNameHasExt,
 | 
						|
        onProgress,
 | 
						|
        destDir,
 | 
						|
        useExisting: useExisting,
 | 
						|
        headers: headers,
 | 
						|
        retries: (retries - 1),
 | 
						|
        allowInsecure: allowInsecure,
 | 
						|
        logs: logs,
 | 
						|
      );
 | 
						|
    } else {
 | 
						|
      rethrow;
 | 
						|
    }
 | 
						|
  }
 | 
						|
}
 | 
						|
 | 
						|
String hashListOfLists(List<List<int>> data) {
 | 
						|
  var bytes = utf8.encode(jsonEncode(data));
 | 
						|
  var digest = sha256.convert(bytes);
 | 
						|
  var hash = digest.toString();
 | 
						|
  return hash.hashCode.toString();
 | 
						|
}
 | 
						|
 | 
						|
Future<String> checkPartialDownloadHashDynamic(
 | 
						|
  String url, {
 | 
						|
  int startingSize = 1024,
 | 
						|
  int lowerLimit = 128,
 | 
						|
  Map<String, String>? headers,
 | 
						|
  bool allowInsecure = false,
 | 
						|
}) async {
 | 
						|
  for (int i = startingSize; i >= lowerLimit; i -= 256) {
 | 
						|
    List<String> ab = await Future.wait([
 | 
						|
      checkPartialDownloadHash(
 | 
						|
        url,
 | 
						|
        i,
 | 
						|
        headers: headers,
 | 
						|
        allowInsecure: allowInsecure,
 | 
						|
      ),
 | 
						|
      checkPartialDownloadHash(
 | 
						|
        url,
 | 
						|
        i,
 | 
						|
        headers: headers,
 | 
						|
        allowInsecure: allowInsecure,
 | 
						|
      ),
 | 
						|
    ]);
 | 
						|
    if (ab[0] == ab[1]) {
 | 
						|
      return ab[0];
 | 
						|
    }
 | 
						|
  }
 | 
						|
  throw NoVersionError();
 | 
						|
}
 | 
						|
 | 
						|
Future<String> checkPartialDownloadHash(
 | 
						|
  String url,
 | 
						|
  int bytesToGrab, {
 | 
						|
  Map<String, String>? headers,
 | 
						|
  bool allowInsecure = false,
 | 
						|
}) async {
 | 
						|
  var req = Request('GET', Uri.parse(url));
 | 
						|
  if (headers != null) {
 | 
						|
    req.headers.addAll(headers);
 | 
						|
  }
 | 
						|
  req.headers[HttpHeaders.rangeHeader] = 'bytes=0-$bytesToGrab';
 | 
						|
  var client = IOClient(createHttpClient(allowInsecure));
 | 
						|
  var response = await client.send(req);
 | 
						|
  if (response.statusCode < 200 || response.statusCode > 299) {
 | 
						|
    throw ObtainiumError(response.reasonPhrase ?? tr('unexpectedError'));
 | 
						|
  }
 | 
						|
  List<List<int>> bytes = await response.stream.take(bytesToGrab).toList();
 | 
						|
  return hashListOfLists(bytes);
 | 
						|
}
 | 
						|
 | 
						|
Future<String?> checkETagHeader(
 | 
						|
  String url, {
 | 
						|
  Map<String, String>? headers,
 | 
						|
  bool allowInsecure = false,
 | 
						|
}) async {
 | 
						|
  // Send the initial request but cancel it as soon as you have the headers
 | 
						|
  var reqHeaders = headers ?? {};
 | 
						|
  var req = Request('GET', Uri.parse(url));
 | 
						|
  req.headers.addAll(reqHeaders);
 | 
						|
  var client = IOClient(createHttpClient(allowInsecure));
 | 
						|
  StreamedResponse response = await client.send(req);
 | 
						|
  var resHeaders = response.headers;
 | 
						|
  client.close();
 | 
						|
  return resHeaders[HttpHeaders.etagHeader]
 | 
						|
      ?.replaceAll('"', '')
 | 
						|
      .hashCode
 | 
						|
      .toString();
 | 
						|
}
 | 
						|
 | 
						|
void deleteFile(File file) {
 | 
						|
  try {
 | 
						|
    file.deleteSync(recursive: true);
 | 
						|
  } on PathAccessException catch (e) {
 | 
						|
    throw ObtainiumError(
 | 
						|
      tr('fileDeletionError', args: [e.path ?? tr('unknown')]),
 | 
						|
    );
 | 
						|
  }
 | 
						|
}
 | 
						|
 | 
						|
Future<File> downloadFile(
 | 
						|
  String url,
 | 
						|
  String fileName,
 | 
						|
  bool fileNameHasExt,
 | 
						|
  Function? onProgress,
 | 
						|
  String destDir, {
 | 
						|
  bool useExisting = true,
 | 
						|
  Map<String, String>? headers,
 | 
						|
  bool allowInsecure = false,
 | 
						|
  LogsProvider? logs,
 | 
						|
}) async {
 | 
						|
  // Send the initial request but cancel it as soon as you have the headers
 | 
						|
  var reqHeaders = headers ?? {};
 | 
						|
  var req = Request('GET', Uri.parse(url));
 | 
						|
  req.headers.addAll(reqHeaders);
 | 
						|
  var headersClient = IOClient(createHttpClient(allowInsecure));
 | 
						|
  StreamedResponse headersResponse = await headersClient.send(req);
 | 
						|
  var resHeaders = headersResponse.headers;
 | 
						|
 | 
						|
  // Use the headers to decide what the file extension is, and
 | 
						|
  // whether it supports partial downloads (range request), and
 | 
						|
  // what the total size of the file is (if provided)
 | 
						|
  String ext = resHeaders['content-disposition']?.split('.').last ?? 'apk';
 | 
						|
  if (ext.endsWith('"') || ext.endsWith("other")) {
 | 
						|
    ext = ext.substring(0, ext.length - 1);
 | 
						|
  }
 | 
						|
  if (((Uri.tryParse(url)?.path ?? url).toLowerCase().endsWith('.apk') ||
 | 
						|
          ext == 'attachment') &&
 | 
						|
      ext != 'apk') {
 | 
						|
    ext = 'apk';
 | 
						|
  }
 | 
						|
  fileName = fileNameHasExt
 | 
						|
      ? fileName
 | 
						|
      : fileName.split('/').last; // Ensure the fileName is a file name
 | 
						|
  File downloadedFile = File('$destDir/$fileName.$ext');
 | 
						|
  if (fileNameHasExt) {
 | 
						|
    // If the user says the filename already has an ext, ignore whatever you inferred from above
 | 
						|
    downloadedFile = File('$destDir/$fileName');
 | 
						|
  }
 | 
						|
 | 
						|
  bool rangeFeatureEnabled = false;
 | 
						|
  if (resHeaders['accept-ranges']?.isNotEmpty == true) {
 | 
						|
    rangeFeatureEnabled =
 | 
						|
        resHeaders['accept-ranges']?.trim().toLowerCase() == 'bytes';
 | 
						|
  }
 | 
						|
  headersClient.close();
 | 
						|
 | 
						|
  // If you have an existing file that is usable,
 | 
						|
  // decide whether you can use it (either return full or resume partial)
 | 
						|
  var fullContentLength = headersResponse.contentLength;
 | 
						|
  if (useExisting && downloadedFile.existsSync()) {
 | 
						|
    var length = downloadedFile.lengthSync();
 | 
						|
    if (fullContentLength == null || !rangeFeatureEnabled) {
 | 
						|
      // If there is no content length reported, assume it the existing file is fully downloaded
 | 
						|
      // Also if the range feature is not supported, don't trust the content length if any (#1542)
 | 
						|
      return downloadedFile;
 | 
						|
    } else {
 | 
						|
      // Check if resume needed/possible
 | 
						|
      if (length == fullContentLength) {
 | 
						|
        return downloadedFile;
 | 
						|
      }
 | 
						|
      if (length > fullContentLength) {
 | 
						|
        useExisting = false;
 | 
						|
      }
 | 
						|
    }
 | 
						|
  }
 | 
						|
 | 
						|
  // Download to a '.temp' file (to distinguish btn. complete/incomplete files)
 | 
						|
  File tempDownloadedFile = File('${downloadedFile.path}.part');
 | 
						|
 | 
						|
  // If there is already a temp file, a download may already be in progress - account for this (see #2073)
 | 
						|
  bool tempFileExists = tempDownloadedFile.existsSync();
 | 
						|
  if (tempFileExists && useExisting) {
 | 
						|
    logs?.add(
 | 
						|
      'Partial download exists - will wait: ${tempDownloadedFile.uri.pathSegments.last}',
 | 
						|
    );
 | 
						|
    bool isDownloading = true;
 | 
						|
    int currentTempFileSize = await tempDownloadedFile.length();
 | 
						|
    bool shouldReturn = false;
 | 
						|
    while (isDownloading) {
 | 
						|
      await Future.delayed(Duration(seconds: 7));
 | 
						|
      if (tempDownloadedFile.existsSync()) {
 | 
						|
        int newTempFileSize = await tempDownloadedFile.length();
 | 
						|
        if (newTempFileSize > currentTempFileSize) {
 | 
						|
          currentTempFileSize = newTempFileSize;
 | 
						|
          logs?.add(
 | 
						|
            'Existing partial download still in progress: ${tempDownloadedFile.uri.pathSegments.last}',
 | 
						|
          );
 | 
						|
        } else {
 | 
						|
          logs?.add(
 | 
						|
            'Ignoring existing partial download: ${tempDownloadedFile.uri.pathSegments.last}',
 | 
						|
          );
 | 
						|
          break;
 | 
						|
        }
 | 
						|
      } else {
 | 
						|
        shouldReturn = downloadedFile.existsSync();
 | 
						|
      }
 | 
						|
    }
 | 
						|
    if (shouldReturn) {
 | 
						|
      logs?.add(
 | 
						|
        'Existing partial download completed - not repeating: ${tempDownloadedFile.uri.pathSegments.last}',
 | 
						|
      );
 | 
						|
      return downloadedFile;
 | 
						|
    } else {
 | 
						|
      logs?.add(
 | 
						|
        'Existing partial download not in progress: ${tempDownloadedFile.uri.pathSegments.last}',
 | 
						|
      );
 | 
						|
    }
 | 
						|
  }
 | 
						|
 | 
						|
  // If the range feature is not available (or you need to start a ranged req from 0),
 | 
						|
  // complete the already-started request, else cancel it and start a ranged request,
 | 
						|
  // and open the file for writing in the appropriate mode
 | 
						|
  var targetFileLength = useExisting && tempDownloadedFile.existsSync()
 | 
						|
      ? tempDownloadedFile.lengthSync()
 | 
						|
      : null;
 | 
						|
  int rangeStart = targetFileLength ?? 0;
 | 
						|
  IOSink? sink;
 | 
						|
  req = Request('GET', Uri.parse(url));
 | 
						|
  req.headers.addAll(reqHeaders);
 | 
						|
  if (rangeFeatureEnabled && fullContentLength != null && rangeStart > 0) {
 | 
						|
    reqHeaders.addAll({'range': 'bytes=$rangeStart-${fullContentLength - 1}'});
 | 
						|
    sink = tempDownloadedFile.openWrite(mode: FileMode.writeOnlyAppend);
 | 
						|
  } else if (tempDownloadedFile.existsSync()) {
 | 
						|
    deleteFile(tempDownloadedFile);
 | 
						|
  }
 | 
						|
  var responseWithClient = await sourceRequestStreamResponse(
 | 
						|
    'GET',
 | 
						|
    url,
 | 
						|
    reqHeaders,
 | 
						|
    {},
 | 
						|
  );
 | 
						|
  HttpClient responseClient = responseWithClient.value.key;
 | 
						|
  HttpClientResponse response = responseWithClient.value.value;
 | 
						|
  sink ??= tempDownloadedFile.openWrite(mode: FileMode.writeOnly);
 | 
						|
 | 
						|
  // Perform the download
 | 
						|
  var received = 0;
 | 
						|
  double? progress;
 | 
						|
  DateTime? lastProgressUpdate; // Track last progress update time
 | 
						|
  if (rangeStart > 0 && fullContentLength != null) {
 | 
						|
    received = rangeStart;
 | 
						|
  }
 | 
						|
  const downloadUIUpdateInterval = Duration(milliseconds: 500);
 | 
						|
  const downloadBufferSize = 32 * 1024; // 32KB
 | 
						|
  final downloadBuffer = BytesBuilder();
 | 
						|
  await response
 | 
						|
      .asBroadcastStream()
 | 
						|
      .map((chunk) {
 | 
						|
        received += chunk.length;
 | 
						|
        final now = DateTime.now();
 | 
						|
        if (onProgress != null &&
 | 
						|
            (lastProgressUpdate == null ||
 | 
						|
                now.difference(lastProgressUpdate!) >=
 | 
						|
                    downloadUIUpdateInterval)) {
 | 
						|
          progress = fullContentLength != null
 | 
						|
              ? (received / fullContentLength) * 100
 | 
						|
              : 30;
 | 
						|
          onProgress(progress);
 | 
						|
          lastProgressUpdate = now;
 | 
						|
        }
 | 
						|
        return chunk;
 | 
						|
      })
 | 
						|
      .transform(
 | 
						|
        StreamTransformer<List<int>, List<int>>.fromHandlers(
 | 
						|
          handleData: (List<int> data, EventSink<List<int>> s) {
 | 
						|
            downloadBuffer.add(data);
 | 
						|
            if (downloadBuffer.length >= downloadBufferSize) {
 | 
						|
              s.add(downloadBuffer.takeBytes());
 | 
						|
            }
 | 
						|
          },
 | 
						|
          handleDone: (EventSink<List<int>> s) {
 | 
						|
            if (downloadBuffer.isNotEmpty) {
 | 
						|
              s.add(downloadBuffer.takeBytes());
 | 
						|
            }
 | 
						|
            s.close();
 | 
						|
          },
 | 
						|
        ),
 | 
						|
      )
 | 
						|
      .pipe(sink);
 | 
						|
  await sink.close();
 | 
						|
  progress = null;
 | 
						|
  if (onProgress != null) {
 | 
						|
    onProgress(progress);
 | 
						|
  }
 | 
						|
  if (response.statusCode < 200 || response.statusCode > 299) {
 | 
						|
    deleteFile(tempDownloadedFile);
 | 
						|
    throw response.reasonPhrase;
 | 
						|
  }
 | 
						|
  if (tempDownloadedFile.existsSync()) {
 | 
						|
    tempDownloadedFile.renameSync(downloadedFile.path);
 | 
						|
  }
 | 
						|
  responseClient.close();
 | 
						|
  return downloadedFile;
 | 
						|
}
 | 
						|
 | 
						|
Future<List<PackageInfo>> getAllInstalledInfo() async {
 | 
						|
  return await pm.getInstalledPackages() ?? [];
 | 
						|
}
 | 
						|
 | 
						|
Future<PackageInfo?> getInstalledInfo(
 | 
						|
  String? packageName, {
 | 
						|
  bool printErr = true,
 | 
						|
}) async {
 | 
						|
  if (packageName != null) {
 | 
						|
    try {
 | 
						|
      return await pm.getPackageInfo(packageName: packageName);
 | 
						|
    } catch (e) {
 | 
						|
      if (printErr) {
 | 
						|
        print(e); // OK
 | 
						|
      }
 | 
						|
    }
 | 
						|
  }
 | 
						|
  return null;
 | 
						|
}
 | 
						|
 | 
						|
class AppsProvider with ChangeNotifier {
 | 
						|
  // In memory App state (should always be kept in sync with local storage versions)
 | 
						|
  Map<String, AppInMemory> apps = {};
 | 
						|
  bool loadingApps = false;
 | 
						|
  bool gettingUpdates = false;
 | 
						|
  LogsProvider logs = LogsProvider();
 | 
						|
 | 
						|
  // Variables to keep track of the app foreground status (installs can't run in the background)
 | 
						|
  bool isForeground = true;
 | 
						|
  late Stream<FGBGType>? foregroundStream;
 | 
						|
  late StreamSubscription<FGBGType>? foregroundSubscription;
 | 
						|
  late Directory APKDir;
 | 
						|
  late Directory iconsCacheDir;
 | 
						|
  late SettingsProvider settingsProvider = SettingsProvider();
 | 
						|
 | 
						|
  Iterable<AppInMemory> getAppValues() => apps.values.map((a) => a.deepCopy());
 | 
						|
 | 
						|
  AppsProvider({isBg = false}) {
 | 
						|
    // Subscribe to changes in the app foreground status
 | 
						|
    foregroundStream = FGBGEvents.instance.stream.asBroadcastStream();
 | 
						|
    foregroundSubscription = foregroundStream?.listen((event) async {
 | 
						|
      isForeground = event == FGBGType.foreground;
 | 
						|
      if (isForeground) {
 | 
						|
        await loadApps();
 | 
						|
      }
 | 
						|
    });
 | 
						|
    () async {
 | 
						|
      await settingsProvider.initializeSettings();
 | 
						|
      var cacheDirs = await getExternalCacheDirectories();
 | 
						|
      if (cacheDirs?.isNotEmpty ?? false) {
 | 
						|
        APKDir = cacheDirs!.first;
 | 
						|
        iconsCacheDir = Directory('${cacheDirs.first.path}/icons');
 | 
						|
        if (!iconsCacheDir.existsSync()) {
 | 
						|
          iconsCacheDir.createSync();
 | 
						|
        }
 | 
						|
      } else {
 | 
						|
        APKDir = Directory(
 | 
						|
          '${(await getExternalStorageDirectory())!.path}/apks',
 | 
						|
        );
 | 
						|
        if (!APKDir.existsSync()) {
 | 
						|
          APKDir.createSync();
 | 
						|
        }
 | 
						|
        iconsCacheDir = Directory(
 | 
						|
          '${(await getExternalStorageDirectory())!.path}/icons',
 | 
						|
        );
 | 
						|
        if (!iconsCacheDir.existsSync()) {
 | 
						|
          iconsCacheDir.createSync();
 | 
						|
        }
 | 
						|
      }
 | 
						|
      if (!isBg) {
 | 
						|
        // Load Apps into memory (in background processes, this is done later instead of in the constructor)
 | 
						|
        await loadApps();
 | 
						|
        // Delete any partial APKs (if safe to do so)
 | 
						|
        var cutoff = DateTime.now().subtract(const Duration(days: 7));
 | 
						|
        APKDir.listSync()
 | 
						|
            .where((element) => element.statSync().modified.isBefore(cutoff))
 | 
						|
            .forEach((partialApk) {
 | 
						|
              if (!areDownloadsRunning()) {
 | 
						|
                partialApk.delete(recursive: true);
 | 
						|
              }
 | 
						|
            });
 | 
						|
      }
 | 
						|
    }();
 | 
						|
  }
 | 
						|
 | 
						|
  Future<File> handleAPKIDChange(
 | 
						|
    App app,
 | 
						|
    PackageInfo newInfo,
 | 
						|
    File downloadedFile,
 | 
						|
    String downloadUrl,
 | 
						|
  ) async {
 | 
						|
    // If the APK package ID is different from the App ID, it is either new (using a placeholder ID) or the ID has changed
 | 
						|
    // The former case should be handled (give the App its real ID), the latter is a security issue
 | 
						|
    var isTempIdBool = isTempId(app);
 | 
						|
    if (app.id != newInfo.packageName) {
 | 
						|
      if (apps[app.id] != null && !isTempIdBool && !app.allowIdChange) {
 | 
						|
        throw IDChangedError(newInfo.packageName!);
 | 
						|
      }
 | 
						|
      var idChangeWasAllowed = app.allowIdChange;
 | 
						|
      app.allowIdChange = false;
 | 
						|
      var originalAppId = app.id;
 | 
						|
      app.id = newInfo.packageName!;
 | 
						|
      downloadedFile = downloadedFile.renameSync(
 | 
						|
        '${downloadedFile.parent.path}/${app.id}-${downloadUrl.hashCode}.${downloadedFile.path.split('.').last}',
 | 
						|
      );
 | 
						|
      if (apps[originalAppId] != null) {
 | 
						|
        await removeApps([originalAppId]);
 | 
						|
        await saveApps([
 | 
						|
          app,
 | 
						|
        ], onlyIfExists: !isTempIdBool && !idChangeWasAllowed);
 | 
						|
      }
 | 
						|
    }
 | 
						|
    return downloadedFile;
 | 
						|
  }
 | 
						|
 | 
						|
  Future<Object> downloadApp(
 | 
						|
    App app,
 | 
						|
    BuildContext? context, {
 | 
						|
    NotificationsProvider? notificationsProvider,
 | 
						|
    bool useExisting = true,
 | 
						|
  }) async {
 | 
						|
    var notifId = DownloadNotification(app.finalName, 0).id;
 | 
						|
    if (apps[app.id] != null) {
 | 
						|
      apps[app.id]!.downloadProgress = 0;
 | 
						|
      notifyListeners();
 | 
						|
    }
 | 
						|
    try {
 | 
						|
      AppSource source = SourceProvider().getSource(
 | 
						|
        app.url,
 | 
						|
        overrideSource: app.overrideSource,
 | 
						|
      );
 | 
						|
      String downloadUrl = await source.apkUrlPrefetchModifier(
 | 
						|
        app.apkUrls[app.preferredApkIndex].value,
 | 
						|
        app.url,
 | 
						|
        app.additionalSettings,
 | 
						|
      );
 | 
						|
      var notif = DownloadNotification(app.finalName, 100);
 | 
						|
      notificationsProvider?.cancel(notif.id);
 | 
						|
      int? prevProg;
 | 
						|
      var fileNameNoExt = '${app.id}-${downloadUrl.hashCode}';
 | 
						|
      if (source.urlsAlwaysHaveExtension) {
 | 
						|
        fileNameNoExt =
 | 
						|
            '$fileNameNoExt.${app.apkUrls[app.preferredApkIndex].key.split('.').last}';
 | 
						|
      }
 | 
						|
      var headers = await source.getRequestHeaders(
 | 
						|
        app.additionalSettings,
 | 
						|
        forAPKDownload: true,
 | 
						|
      );
 | 
						|
      var downloadedFile = await downloadFileWithRetry(
 | 
						|
        downloadUrl,
 | 
						|
        fileNameNoExt,
 | 
						|
        source.urlsAlwaysHaveExtension,
 | 
						|
        headers: headers,
 | 
						|
        (double? progress) {
 | 
						|
          int? prog = progress?.ceil();
 | 
						|
          if (apps[app.id] != null) {
 | 
						|
            apps[app.id]!.downloadProgress = progress;
 | 
						|
            notifyListeners();
 | 
						|
          }
 | 
						|
          notif = DownloadNotification(app.finalName, prog ?? 100);
 | 
						|
          if (prog != null && prevProg != prog) {
 | 
						|
            notificationsProvider?.notify(notif);
 | 
						|
          }
 | 
						|
          prevProg = prog;
 | 
						|
        },
 | 
						|
        APKDir.path,
 | 
						|
        useExisting: useExisting,
 | 
						|
        allowInsecure: app.additionalSettings['allowInsecure'] == true,
 | 
						|
        logs: logs,
 | 
						|
      );
 | 
						|
      // Set to 90 for remaining steps, will make null in 'finally'
 | 
						|
      if (apps[app.id] != null) {
 | 
						|
        apps[app.id]!.downloadProgress = -1;
 | 
						|
        notifyListeners();
 | 
						|
        notif = DownloadNotification(app.finalName, -1);
 | 
						|
        notificationsProvider?.notify(notif);
 | 
						|
      }
 | 
						|
      PackageInfo? newInfo;
 | 
						|
      var isAPK = downloadedFile.path.toLowerCase().endsWith('.apk');
 | 
						|
      Directory? xapkDir;
 | 
						|
      if (isAPK) {
 | 
						|
        newInfo = await pm.getPackageArchiveInfo(
 | 
						|
          archiveFilePath: downloadedFile.path,
 | 
						|
        );
 | 
						|
      } else {
 | 
						|
        // Assume XAPK
 | 
						|
        String xapkDirPath = '${downloadedFile.path}-dir';
 | 
						|
        await unzipFile(downloadedFile.path, '${downloadedFile.path}-dir');
 | 
						|
        xapkDir = Directory(xapkDirPath);
 | 
						|
        var apks = xapkDir
 | 
						|
            .listSync()
 | 
						|
            .where((e) => e.path.toLowerCase().endsWith('.apk'))
 | 
						|
            .toList();
 | 
						|
 | 
						|
        FileSystemEntity? temp;
 | 
						|
        apks.removeWhere((element) {
 | 
						|
          bool res = element.uri.pathSegments.last.startsWith(app.id);
 | 
						|
          if (res) {
 | 
						|
            temp = element;
 | 
						|
          }
 | 
						|
          return res;
 | 
						|
        });
 | 
						|
        if (temp != null) {
 | 
						|
          apks = [temp!, ...apks];
 | 
						|
        }
 | 
						|
 | 
						|
        for (var i = 0; i < apks.length; i++) {
 | 
						|
          try {
 | 
						|
            newInfo = await pm.getPackageArchiveInfo(
 | 
						|
              archiveFilePath: apks[i].path,
 | 
						|
            );
 | 
						|
            if (newInfo != null) {
 | 
						|
              break;
 | 
						|
            }
 | 
						|
          } catch (e) {
 | 
						|
            if (i == apks.length - 1) {
 | 
						|
              rethrow;
 | 
						|
            }
 | 
						|
          }
 | 
						|
        }
 | 
						|
      }
 | 
						|
      if (newInfo == null) {
 | 
						|
        downloadedFile.delete();
 | 
						|
        throw ObtainiumError('Could not get ID from APK');
 | 
						|
      }
 | 
						|
      downloadedFile = await handleAPKIDChange(
 | 
						|
        app,
 | 
						|
        newInfo,
 | 
						|
        downloadedFile,
 | 
						|
        downloadUrl,
 | 
						|
      );
 | 
						|
      // Delete older versions of the file if any
 | 
						|
      for (var file in downloadedFile.parent.listSync()) {
 | 
						|
        var fn = file.path.split('/').last;
 | 
						|
        if (fn.startsWith('${app.id}-') &&
 | 
						|
            FileSystemEntity.isFileSync(file.path) &&
 | 
						|
            file.path != downloadedFile.path) {
 | 
						|
          file.delete(recursive: true);
 | 
						|
        }
 | 
						|
      }
 | 
						|
      if (isAPK) {
 | 
						|
        return DownloadedApk(app.id, downloadedFile);
 | 
						|
      } else {
 | 
						|
        return DownloadedXApkDir(app.id, downloadedFile, xapkDir!);
 | 
						|
      }
 | 
						|
    } finally {
 | 
						|
      notificationsProvider?.cancel(notifId);
 | 
						|
      if (apps[app.id] != null) {
 | 
						|
        apps[app.id]!.downloadProgress = null;
 | 
						|
        notifyListeners();
 | 
						|
      }
 | 
						|
    }
 | 
						|
  }
 | 
						|
 | 
						|
  bool areDownloadsRunning() => apps.values
 | 
						|
      .where((element) => element.downloadProgress != null)
 | 
						|
      .isNotEmpty;
 | 
						|
 | 
						|
  Future<bool> canInstallSilently(App app) async {
 | 
						|
    if (!settingsProvider.enableBackgroundUpdates) {
 | 
						|
      return false;
 | 
						|
    }
 | 
						|
    if (app.additionalSettings['exemptFromBackgroundUpdates'] == true) {
 | 
						|
      logs.add('Exempted from BG updates: ${app.id}');
 | 
						|
      return false;
 | 
						|
    }
 | 
						|
    if (app.apkUrls.length > 1) {
 | 
						|
      logs.add('Multiple APK URLs: ${app.id}');
 | 
						|
      return false; // Manual API selection means silent install is not possible
 | 
						|
    }
 | 
						|
 | 
						|
    var osInfo = await DeviceInfoPlugin().androidInfo;
 | 
						|
    String? installerPackageName;
 | 
						|
    try {
 | 
						|
      installerPackageName = osInfo.version.sdkInt >= 30
 | 
						|
          ? (await pm.getInstallSourceInfo(
 | 
						|
              packageName: app.id,
 | 
						|
            ))?.installingPackageName
 | 
						|
          : (await pm.getInstallerPackageName(packageName: app.id));
 | 
						|
    } catch (e) {
 | 
						|
      logs.add(
 | 
						|
        'Failed to get installed package details: ${app.id} (${e.toString()})',
 | 
						|
      );
 | 
						|
      return false; // App probably not installed
 | 
						|
    }
 | 
						|
 | 
						|
    int? targetSDK = (await getInstalledInfo(
 | 
						|
      app.id,
 | 
						|
    ))?.applicationInfo?.targetSdkVersion;
 | 
						|
    // The APK should target a new enough API
 | 
						|
    // https://developer.android.com/reference/android/content/pm/PackageInstaller.SessionParams#setRequireUserAction(int)
 | 
						|
    if (!(targetSDK != null && targetSDK >= (osInfo.version.sdkInt - 3))) {
 | 
						|
      logs.add('Multiple APK URLs: ${app.id}');
 | 
						|
      return false;
 | 
						|
    }
 | 
						|
 | 
						|
    if (settingsProvider.useShizuku) {
 | 
						|
      return true;
 | 
						|
    }
 | 
						|
 | 
						|
    if (app.id == obtainiumId) {
 | 
						|
      return false;
 | 
						|
    }
 | 
						|
    if (installerPackageName != obtainiumId) {
 | 
						|
      // If we did not install the app, silent install is not possible
 | 
						|
      return false;
 | 
						|
    }
 | 
						|
    if (osInfo.version.sdkInt < 31) {
 | 
						|
      // The OS must also be new enough
 | 
						|
      logs.add('Android SDK too old: ${osInfo.version.sdkInt}');
 | 
						|
      return false;
 | 
						|
    }
 | 
						|
    return true;
 | 
						|
  }
 | 
						|
 | 
						|
  Future<void> waitForUserToReturnToForeground(BuildContext context) async {
 | 
						|
    NotificationsProvider notificationsProvider = context
 | 
						|
        .read<NotificationsProvider>();
 | 
						|
    if (!isForeground) {
 | 
						|
      await notificationsProvider.notify(
 | 
						|
        completeInstallationNotification,
 | 
						|
        cancelExisting: true,
 | 
						|
      );
 | 
						|
      while (await FGBGEvents.instance.stream.first != FGBGType.foreground) {}
 | 
						|
      await notificationsProvider.cancel(completeInstallationNotification.id);
 | 
						|
    }
 | 
						|
  }
 | 
						|
 | 
						|
  Future<bool> canDowngradeApps() async =>
 | 
						|
      (await getInstalledInfo('com.berdik.letmedowngrade')) != null;
 | 
						|
 | 
						|
  Future<void> unzipFile(String filePath, String destinationPath) async {
 | 
						|
    await ZipFile.extractToDirectory(
 | 
						|
      zipFile: File(filePath),
 | 
						|
      destinationDir: Directory(destinationPath),
 | 
						|
    );
 | 
						|
  }
 | 
						|
 | 
						|
  Future<bool> installXApkDir(
 | 
						|
    DownloadedXApkDir dir,
 | 
						|
    BuildContext? firstTimeWithContext, {
 | 
						|
    bool needsBGWorkaround = false,
 | 
						|
    bool shizukuPretendToBeGooglePlay = false,
 | 
						|
  }) async {
 | 
						|
    // We don't know which APKs in an XAPK are supported by the user's device
 | 
						|
    // So we try installing all of them and assume success if at least one installed
 | 
						|
    // If 0 APKs installed, throw the first install error encountered
 | 
						|
    var somethingInstalled = false;
 | 
						|
    try {
 | 
						|
      MultiAppMultiError errors = MultiAppMultiError();
 | 
						|
      List<File> APKFiles = [];
 | 
						|
      for (var file
 | 
						|
          in dir.extracted
 | 
						|
              .listSync(recursive: true, followLinks: false)
 | 
						|
              .whereType<File>()) {
 | 
						|
        if (file.path.toLowerCase().endsWith('.apk')) {
 | 
						|
          APKFiles.add(file);
 | 
						|
        } else if (file.path.toLowerCase().endsWith('.obb')) {
 | 
						|
          await moveObbFile(file, dir.appId);
 | 
						|
        }
 | 
						|
      }
 | 
						|
 | 
						|
      File? temp;
 | 
						|
      APKFiles.removeWhere((element) {
 | 
						|
        bool res = element.uri.pathSegments.last.startsWith(dir.appId);
 | 
						|
        if (res) {
 | 
						|
          temp = element;
 | 
						|
        }
 | 
						|
        return res;
 | 
						|
      });
 | 
						|
      if (temp != null) {
 | 
						|
        APKFiles = [temp!, ...APKFiles];
 | 
						|
      }
 | 
						|
 | 
						|
      try {
 | 
						|
        await installApk(
 | 
						|
          DownloadedApk(dir.appId, APKFiles[0]),
 | 
						|
          firstTimeWithContext,
 | 
						|
          needsBGWorkaround: needsBGWorkaround,
 | 
						|
          shizukuPretendToBeGooglePlay: shizukuPretendToBeGooglePlay,
 | 
						|
          additionalAPKs: APKFiles.sublist(
 | 
						|
            1,
 | 
						|
          ).map((a) => DownloadedApk(dir.appId, a)).toList(),
 | 
						|
        );
 | 
						|
        somethingInstalled = true;
 | 
						|
        dir.file.delete(recursive: true);
 | 
						|
      } catch (e) {
 | 
						|
        logs.add('Could not install APKs from XAPK: ${e.toString()}');
 | 
						|
        errors.add(dir.appId, e, appName: apps[dir.appId]?.name);
 | 
						|
      }
 | 
						|
      if (errors.idsByErrorString.isNotEmpty) {
 | 
						|
        throw errors;
 | 
						|
      }
 | 
						|
    } finally {
 | 
						|
      dir.extracted.delete(recursive: true);
 | 
						|
    }
 | 
						|
    return somethingInstalled;
 | 
						|
  }
 | 
						|
 | 
						|
  Future<bool> installApk(
 | 
						|
    DownloadedApk file,
 | 
						|
    BuildContext? firstTimeWithContext, {
 | 
						|
    bool needsBGWorkaround = false,
 | 
						|
    bool shizukuPretendToBeGooglePlay = false,
 | 
						|
    List<DownloadedApk> additionalAPKs = const [],
 | 
						|
  }) async {
 | 
						|
    if (firstTimeWithContext != null &&
 | 
						|
        settingsProvider.beforeNewInstallsShareToAppVerifier &&
 | 
						|
        (await getInstalledInfo('dev.soupslurpr.appverifier')) != null) {
 | 
						|
      XFile f = XFile.fromData(
 | 
						|
        file.file.readAsBytesSync(),
 | 
						|
        mimeType: 'application/vnd.android.package-archive',
 | 
						|
      );
 | 
						|
      Fluttertoast.showToast(
 | 
						|
        msg: tr('appVerifierInstructionToast'),
 | 
						|
        toastLength: Toast.LENGTH_LONG,
 | 
						|
      );
 | 
						|
      await Share.shareXFiles([f]);
 | 
						|
    }
 | 
						|
    var newInfo = await pm.getPackageArchiveInfo(
 | 
						|
      archiveFilePath: file.file.path,
 | 
						|
    );
 | 
						|
    if (newInfo == null) {
 | 
						|
      try {
 | 
						|
        deleteFile(file.file);
 | 
						|
        for (var a in additionalAPKs) {
 | 
						|
          deleteFile(a.file);
 | 
						|
        }
 | 
						|
      } catch (e) {
 | 
						|
        //
 | 
						|
      } finally {
 | 
						|
        throw ObtainiumError(tr('badDownload'));
 | 
						|
      }
 | 
						|
    }
 | 
						|
    PackageInfo? appInfo = await getInstalledInfo(apps[file.appId]!.app.id);
 | 
						|
    logs.add(
 | 
						|
      'Installing "${newInfo.packageName}" version "${newInfo.versionName}" versionCode "${newInfo.versionCode}"${appInfo != null ? ' (from existing version "${appInfo.versionName}" versionCode "${appInfo.versionCode}")' : ''}',
 | 
						|
    );
 | 
						|
    if (appInfo != null &&
 | 
						|
        newInfo.versionCode! < appInfo.versionCode! &&
 | 
						|
        !(await canDowngradeApps())) {
 | 
						|
      throw DowngradeError(appInfo.versionCode!, newInfo.versionCode!);
 | 
						|
    }
 | 
						|
    if (needsBGWorkaround) {
 | 
						|
      // The below 'await' will never return if we are in a background process
 | 
						|
      // To work around this, we should assume the install will be successful
 | 
						|
      // So we update the app's installed version first as we will never get to the later code
 | 
						|
      // We can't conditionally get rid of the 'await' as this causes install fails (BG process times out) - see #896
 | 
						|
      // TODO: When fixed, update this function and the calls to it accordingly
 | 
						|
      apps[file.appId]!.app.installedVersion =
 | 
						|
          apps[file.appId]!.app.latestVersion;
 | 
						|
      await saveApps([
 | 
						|
        apps[file.appId]!.app,
 | 
						|
      ], attemptToCorrectInstallStatus: false);
 | 
						|
    }
 | 
						|
    int? code;
 | 
						|
    if (!settingsProvider.useShizuku) {
 | 
						|
      var allAPKs = [file.file.path];
 | 
						|
      allAPKs.addAll(additionalAPKs.map((a) => a.file.path));
 | 
						|
      code = await AndroidPackageInstaller.installApk(
 | 
						|
        apkFilePath: allAPKs.join(','),
 | 
						|
      );
 | 
						|
    } else {
 | 
						|
      code = await ShizukuApkInstaller.installAPK(
 | 
						|
        file.file.uri.toString(),
 | 
						|
        shizukuPretendToBeGooglePlay ? "com.android.vending" : "",
 | 
						|
      );
 | 
						|
    }
 | 
						|
    bool installed = false;
 | 
						|
    if (code != null && code != 0 && code != 3) {
 | 
						|
      try {
 | 
						|
        deleteFile(file.file);
 | 
						|
      } catch (e) {
 | 
						|
        //
 | 
						|
      } finally {
 | 
						|
        throw InstallError(code);
 | 
						|
      }
 | 
						|
    } else if (code == 0) {
 | 
						|
      installed = true;
 | 
						|
      apps[file.appId]!.app.installedVersion =
 | 
						|
          apps[file.appId]!.app.latestVersion;
 | 
						|
      file.file.delete(recursive: true);
 | 
						|
    }
 | 
						|
    await saveApps([apps[file.appId]!.app]);
 | 
						|
    return installed;
 | 
						|
  }
 | 
						|
 | 
						|
  Future<String> getStorageRootPath() async {
 | 
						|
    return '/${(await getExternalStorageDirectory())!.uri.pathSegments.sublist(0, 3).join('/')}';
 | 
						|
  }
 | 
						|
 | 
						|
  Future<void> moveObbFile(File file, String appId) async {
 | 
						|
    if (!file.path.toLowerCase().endsWith('.obb')) return;
 | 
						|
 | 
						|
    // TODO: Does not support Android 11+
 | 
						|
    if ((await DeviceInfoPlugin().androidInfo).version.sdkInt <= 29) {
 | 
						|
      await Permission.storage.request();
 | 
						|
    }
 | 
						|
 | 
						|
    String obbDirPath = "${await getStorageRootPath()}/Android/obb/$appId";
 | 
						|
    Directory(obbDirPath).createSync(recursive: true);
 | 
						|
 | 
						|
    String obbFileName = file.path.split("/").last;
 | 
						|
    await file.copy("$obbDirPath/$obbFileName");
 | 
						|
  }
 | 
						|
 | 
						|
  void uninstallApp(String appId) async {
 | 
						|
    var intent = AndroidIntent(
 | 
						|
      action: 'android.intent.action.DELETE',
 | 
						|
      data: 'package:$appId',
 | 
						|
      flags: <int>[Flag.FLAG_ACTIVITY_NEW_TASK],
 | 
						|
      package: 'vnd.android.package-archive',
 | 
						|
    );
 | 
						|
    await intent.launch();
 | 
						|
  }
 | 
						|
 | 
						|
  Future<MapEntry<String, String>?> confirmAppFileUrl(
 | 
						|
    App app,
 | 
						|
    BuildContext? context,
 | 
						|
    bool pickAnyAsset, {
 | 
						|
    bool evenIfSingleChoice = false,
 | 
						|
  }) async {
 | 
						|
    var urlsToSelectFrom = app.apkUrls;
 | 
						|
    if (pickAnyAsset) {
 | 
						|
      urlsToSelectFrom = [...urlsToSelectFrom, ...app.otherAssetUrls];
 | 
						|
    }
 | 
						|
    // If the App has more than one APK, the user should pick one (if context provided)
 | 
						|
    MapEntry<String, String>? appFileUrl =
 | 
						|
        urlsToSelectFrom[app.preferredApkIndex >= 0
 | 
						|
            ? app.preferredApkIndex
 | 
						|
            : 0];
 | 
						|
    // get device supported architecture
 | 
						|
    List<String> archs = (await DeviceInfoPlugin().androidInfo).supportedAbis;
 | 
						|
 | 
						|
    if ((urlsToSelectFrom.length > 1 || evenIfSingleChoice) &&
 | 
						|
        context != null) {
 | 
						|
      appFileUrl = await showDialog(
 | 
						|
        // ignore: use_build_context_synchronously
 | 
						|
        context: context,
 | 
						|
        builder: (BuildContext ctx) {
 | 
						|
          return AppFilePicker(
 | 
						|
            app: app,
 | 
						|
            initVal: appFileUrl,
 | 
						|
            archs: archs,
 | 
						|
            pickAnyAsset: pickAnyAsset,
 | 
						|
          );
 | 
						|
        },
 | 
						|
      );
 | 
						|
    }
 | 
						|
    getHost(String url) {
 | 
						|
      if (url == 'placeholder') {
 | 
						|
        return null;
 | 
						|
      }
 | 
						|
      var temp = Uri.parse(url).host.split('.');
 | 
						|
      return temp.sublist(temp.length - 2).join('.');
 | 
						|
    }
 | 
						|
 | 
						|
    // If the picked APK comes from an origin different from the source, get user confirmation (if context provided)
 | 
						|
    if (appFileUrl != null &&
 | 
						|
        ![
 | 
						|
          getHost(app.url),
 | 
						|
          'placeholder',
 | 
						|
        ].contains(getHost(appFileUrl.value)) &&
 | 
						|
        context != null) {
 | 
						|
      if (!(settingsProvider.hideAPKOriginWarning) &&
 | 
						|
          await showDialog(
 | 
						|
                // ignore: use_build_context_synchronously
 | 
						|
                context: context,
 | 
						|
                builder: (BuildContext ctx) {
 | 
						|
                  return APKOriginWarningDialog(
 | 
						|
                    sourceUrl: app.url,
 | 
						|
                    apkUrl: appFileUrl!.value,
 | 
						|
                  );
 | 
						|
                },
 | 
						|
              ) !=
 | 
						|
              true) {
 | 
						|
        appFileUrl = null;
 | 
						|
      }
 | 
						|
    }
 | 
						|
    return appFileUrl;
 | 
						|
  }
 | 
						|
 | 
						|
  // Given a list of AppIds, uses stored info about the apps to download APKs and install them
 | 
						|
  // If the APKs can be installed silently, they are
 | 
						|
  // If no BuildContext is provided, apps that require user interaction are ignored
 | 
						|
  // If user input is needed and the App is in the background, a notification is sent to get the user's attention
 | 
						|
  // Returns an array of Ids for Apps that were successfully downloaded, regardless of installation result
 | 
						|
  Future<List<String>> downloadAndInstallLatestApps(
 | 
						|
    List<String> appIds,
 | 
						|
    BuildContext? context, {
 | 
						|
    NotificationsProvider? notificationsProvider,
 | 
						|
    bool forceParallelDownloads = false,
 | 
						|
    bool useExisting = true,
 | 
						|
  }) async {
 | 
						|
    notificationsProvider =
 | 
						|
        notificationsProvider ?? context?.read<NotificationsProvider>();
 | 
						|
    List<String> appsToInstall = [];
 | 
						|
    List<String> trackOnlyAppsToUpdate = [];
 | 
						|
    // For all specified Apps, filter out those for which:
 | 
						|
    // 1. A URL cannot be picked
 | 
						|
    // 2. That cannot be installed silently (IF no buildContext was given for interactive install)
 | 
						|
    for (var id in appIds) {
 | 
						|
      if (apps[id] == null) {
 | 
						|
        throw ObtainiumError(tr('appNotFound'));
 | 
						|
      }
 | 
						|
      MapEntry<String, String>? apkUrl;
 | 
						|
      var trackOnly = apps[id]!.app.additionalSettings['trackOnly'] == true;
 | 
						|
      var refreshBeforeDownload =
 | 
						|
          apps[id]!.app.additionalSettings['refreshBeforeDownload'] == true ||
 | 
						|
          apps[id]!.app.apkUrls.isNotEmpty &&
 | 
						|
              apps[id]!.app.apkUrls.first.value == 'placeholder';
 | 
						|
      if (refreshBeforeDownload) {
 | 
						|
        await checkUpdate(apps[id]!.app.id);
 | 
						|
      }
 | 
						|
      if (!trackOnly) {
 | 
						|
        // ignore: use_build_context_synchronously
 | 
						|
        apkUrl = await confirmAppFileUrl(apps[id]!.app, context, false);
 | 
						|
      }
 | 
						|
      if (apkUrl != null) {
 | 
						|
        int urlInd = apps[id]!.app.apkUrls
 | 
						|
            .map((e) => e.value)
 | 
						|
            .toList()
 | 
						|
            .indexOf(apkUrl.value);
 | 
						|
        if (urlInd >= 0 && urlInd != apps[id]!.app.preferredApkIndex) {
 | 
						|
          apps[id]!.app.preferredApkIndex = urlInd;
 | 
						|
          await saveApps([apps[id]!.app]);
 | 
						|
        }
 | 
						|
        if (context != null || await canInstallSilently(apps[id]!.app)) {
 | 
						|
          appsToInstall.add(id);
 | 
						|
        }
 | 
						|
      }
 | 
						|
      if (trackOnly) {
 | 
						|
        trackOnlyAppsToUpdate.add(id);
 | 
						|
      }
 | 
						|
    }
 | 
						|
    // Mark all specified track-only apps as latest
 | 
						|
    saveApps(
 | 
						|
      trackOnlyAppsToUpdate.map((e) {
 | 
						|
        var a = apps[e]!.app;
 | 
						|
        a.installedVersion = a.latestVersion;
 | 
						|
        return a;
 | 
						|
      }).toList(),
 | 
						|
    );
 | 
						|
 | 
						|
    // Prepare to download+install Apps
 | 
						|
    MultiAppMultiError errors = MultiAppMultiError();
 | 
						|
    List<String> installedIds = [];
 | 
						|
 | 
						|
    // Move Obtainium to the end of the line (let all other apps update first)
 | 
						|
    appsToInstall = moveStrToEnd(
 | 
						|
      appsToInstall,
 | 
						|
      obtainiumId,
 | 
						|
      strB: obtainiumTempId,
 | 
						|
    );
 | 
						|
    appsToInstall = moveStrToEnd(appsToInstall, '$obtainiumId.fdroid');
 | 
						|
 | 
						|
    Future<void> installFn(
 | 
						|
      String id,
 | 
						|
      bool willBeSilent,
 | 
						|
      DownloadedApk? downloadedFile,
 | 
						|
      DownloadedXApkDir? downloadedDir,
 | 
						|
    ) async {
 | 
						|
      apps[id]?.downloadProgress = -1;
 | 
						|
      notifyListeners();
 | 
						|
      try {
 | 
						|
        bool sayInstalled = true;
 | 
						|
        var contextIfNewInstall = apps[id]?.installedInfo == null
 | 
						|
            ? context
 | 
						|
            : null;
 | 
						|
        bool needBGWorkaround =
 | 
						|
            willBeSilent && context == null && !settingsProvider.useShizuku;
 | 
						|
        bool shizukuPretendToBeGooglePlay =
 | 
						|
            settingsProvider.shizukuPretendToBeGooglePlay ||
 | 
						|
            apps[id]!.app.additionalSettings['shizukuPretendToBeGooglePlay'] ==
 | 
						|
                true;
 | 
						|
        if (downloadedFile != null) {
 | 
						|
          if (needBGWorkaround) {
 | 
						|
            // ignore: use_build_context_synchronously
 | 
						|
            installApk(
 | 
						|
              downloadedFile,
 | 
						|
              contextIfNewInstall,
 | 
						|
              needsBGWorkaround: true,
 | 
						|
              shizukuPretendToBeGooglePlay: shizukuPretendToBeGooglePlay,
 | 
						|
            );
 | 
						|
          } else {
 | 
						|
            // ignore: use_build_context_synchronously
 | 
						|
            sayInstalled = await installApk(
 | 
						|
              downloadedFile,
 | 
						|
              contextIfNewInstall,
 | 
						|
              shizukuPretendToBeGooglePlay: shizukuPretendToBeGooglePlay,
 | 
						|
            );
 | 
						|
          }
 | 
						|
        } else {
 | 
						|
          if (needBGWorkaround) {
 | 
						|
            // ignore: use_build_context_synchronously
 | 
						|
            installXApkDir(
 | 
						|
              downloadedDir!,
 | 
						|
              contextIfNewInstall,
 | 
						|
              needsBGWorkaround: true,
 | 
						|
            );
 | 
						|
          } else {
 | 
						|
            // ignore: use_build_context_synchronously
 | 
						|
            sayInstalled = await installXApkDir(
 | 
						|
              downloadedDir!,
 | 
						|
              contextIfNewInstall,
 | 
						|
              shizukuPretendToBeGooglePlay: shizukuPretendToBeGooglePlay,
 | 
						|
            );
 | 
						|
          }
 | 
						|
        }
 | 
						|
        if (willBeSilent && context == null) {
 | 
						|
          if (!settingsProvider.useShizuku) {
 | 
						|
            notificationsProvider?.notify(
 | 
						|
              SilentUpdateAttemptNotification([apps[id]!.app], id: id.hashCode),
 | 
						|
            );
 | 
						|
          } else {
 | 
						|
            notificationsProvider?.notify(
 | 
						|
              SilentUpdateNotification(
 | 
						|
                [apps[id]!.app],
 | 
						|
                sayInstalled,
 | 
						|
                id: id.hashCode,
 | 
						|
              ),
 | 
						|
            );
 | 
						|
          }
 | 
						|
        }
 | 
						|
        if (sayInstalled) {
 | 
						|
          installedIds.add(id);
 | 
						|
        }
 | 
						|
      } finally {
 | 
						|
        apps[id]?.downloadProgress = null;
 | 
						|
        notifyListeners();
 | 
						|
      }
 | 
						|
    }
 | 
						|
 | 
						|
    Future<Map<Object?, Object?>> downloadFn(
 | 
						|
      String id, {
 | 
						|
      bool skipInstalls = false,
 | 
						|
    }) async {
 | 
						|
      bool willBeSilent = false;
 | 
						|
      DownloadedApk? downloadedFile;
 | 
						|
      DownloadedXApkDir? downloadedDir;
 | 
						|
      try {
 | 
						|
        var downloadedArtifact =
 | 
						|
            // ignore: use_build_context_synchronously
 | 
						|
            await downloadApp(
 | 
						|
              apps[id]!.app,
 | 
						|
              context,
 | 
						|
              notificationsProvider: notificationsProvider,
 | 
						|
              useExisting: useExisting,
 | 
						|
            );
 | 
						|
        if (downloadedArtifact is DownloadedApk) {
 | 
						|
          downloadedFile = downloadedArtifact;
 | 
						|
        } else {
 | 
						|
          downloadedDir = downloadedArtifact as DownloadedXApkDir;
 | 
						|
        }
 | 
						|
        id = downloadedFile?.appId ?? downloadedDir!.appId;
 | 
						|
        willBeSilent = await canInstallSilently(apps[id]!.app);
 | 
						|
        if (!settingsProvider.useShizuku) {
 | 
						|
          if (!(await settingsProvider.getInstallPermission(enforce: false))) {
 | 
						|
            throw ObtainiumError(tr('cancelled'));
 | 
						|
          }
 | 
						|
        } else {
 | 
						|
          switch ((await ShizukuApkInstaller.checkPermission())!) {
 | 
						|
            case 'binder_not_found':
 | 
						|
              throw ObtainiumError(tr('shizukuBinderNotFound'));
 | 
						|
            case 'old_shizuku':
 | 
						|
              throw ObtainiumError(tr('shizukuOld'));
 | 
						|
            case 'old_android_with_adb':
 | 
						|
              throw ObtainiumError(tr('shizukuOldAndroidWithADB'));
 | 
						|
            case 'denied':
 | 
						|
              throw ObtainiumError(tr('cancelled'));
 | 
						|
          }
 | 
						|
        }
 | 
						|
        if (!willBeSilent && context != null && !settingsProvider.useShizuku) {
 | 
						|
          // ignore: use_build_context_synchronously
 | 
						|
          await waitForUserToReturnToForeground(context);
 | 
						|
        }
 | 
						|
      } catch (e) {
 | 
						|
        errors.add(id, e, appName: apps[id]?.name);
 | 
						|
      }
 | 
						|
      return {
 | 
						|
        'id': id,
 | 
						|
        'willBeSilent': willBeSilent,
 | 
						|
        'downloadedFile': downloadedFile,
 | 
						|
        'downloadedDir': downloadedDir,
 | 
						|
      };
 | 
						|
    }
 | 
						|
 | 
						|
    List<Map<Object?, Object?>> downloadResults = [];
 | 
						|
    if (forceParallelDownloads || !settingsProvider.parallelDownloads) {
 | 
						|
      for (var id in appsToInstall) {
 | 
						|
        downloadResults.add(await downloadFn(id));
 | 
						|
      }
 | 
						|
    } else {
 | 
						|
      downloadResults = await Future.wait(
 | 
						|
        appsToInstall.map((id) => downloadFn(id, skipInstalls: true)),
 | 
						|
      );
 | 
						|
    }
 | 
						|
    for (var res in downloadResults) {
 | 
						|
      if (!errors.appIdNames.containsKey(res['id'])) {
 | 
						|
        try {
 | 
						|
          await installFn(
 | 
						|
            res['id'] as String,
 | 
						|
            res['willBeSilent'] as bool,
 | 
						|
            res['downloadedFile'] as DownloadedApk?,
 | 
						|
            res['downloadedDir'] as DownloadedXApkDir?,
 | 
						|
          );
 | 
						|
        } catch (e) {
 | 
						|
          var id = res['id'] as String;
 | 
						|
          errors.add(id, e, appName: apps[id]?.name);
 | 
						|
        }
 | 
						|
      }
 | 
						|
    }
 | 
						|
 | 
						|
    if (errors.idsByErrorString.isNotEmpty) {
 | 
						|
      throw errors;
 | 
						|
    }
 | 
						|
 | 
						|
    return installedIds;
 | 
						|
  }
 | 
						|
 | 
						|
  Future<List<String>> downloadAppAssets(
 | 
						|
    List<String> appIds,
 | 
						|
    BuildContext context, {
 | 
						|
    bool forceParallelDownloads = false,
 | 
						|
  }) async {
 | 
						|
    NotificationsProvider notificationsProvider = context
 | 
						|
        .read<NotificationsProvider>();
 | 
						|
    List<MapEntry<MapEntry<String, String>, App>> filesToDownload = [];
 | 
						|
    for (var id in appIds) {
 | 
						|
      if (apps[id] == null) {
 | 
						|
        throw ObtainiumError(tr('appNotFound'));
 | 
						|
      }
 | 
						|
      MapEntry<String, String>? fileUrl;
 | 
						|
      var refreshBeforeDownload =
 | 
						|
          apps[id]!.app.additionalSettings['refreshBeforeDownload'] == true ||
 | 
						|
          apps[id]!.app.apkUrls.first.value == 'placeholder';
 | 
						|
      if (refreshBeforeDownload) {
 | 
						|
        await checkUpdate(apps[id]!.app.id);
 | 
						|
      }
 | 
						|
      if (apps[id]!.app.apkUrls.isNotEmpty ||
 | 
						|
          apps[id]!.app.otherAssetUrls.isNotEmpty) {
 | 
						|
        // ignore: use_build_context_synchronously
 | 
						|
        MapEntry<String, String>? tempFileUrl = await confirmAppFileUrl(
 | 
						|
          apps[id]!.app,
 | 
						|
          context,
 | 
						|
          true,
 | 
						|
          evenIfSingleChoice: true,
 | 
						|
        );
 | 
						|
        if (tempFileUrl != null) {
 | 
						|
          fileUrl = MapEntry(
 | 
						|
            tempFileUrl.key,
 | 
						|
            await (SourceProvider().getSource(
 | 
						|
              apps[id]!.app.url,
 | 
						|
              overrideSource: apps[id]!.app.overrideSource,
 | 
						|
            )).apkUrlPrefetchModifier(
 | 
						|
              tempFileUrl.value,
 | 
						|
              apps[id]!.app.url,
 | 
						|
              apps[id]!.app.additionalSettings,
 | 
						|
            ),
 | 
						|
          );
 | 
						|
        }
 | 
						|
      }
 | 
						|
      if (fileUrl != null) {
 | 
						|
        filesToDownload.add(MapEntry(fileUrl, apps[id]!.app));
 | 
						|
      }
 | 
						|
    }
 | 
						|
 | 
						|
    // Prepare to download+install Apps
 | 
						|
    MultiAppMultiError errors = MultiAppMultiError();
 | 
						|
    List<String> downloadedIds = [];
 | 
						|
 | 
						|
    Future<void> downloadFn(MapEntry<String, String> fileUrl, App app) async {
 | 
						|
      try {
 | 
						|
        String downloadPath = '${await getStorageRootPath()}/Download';
 | 
						|
        await downloadFile(
 | 
						|
          fileUrl.value,
 | 
						|
          fileUrl.key,
 | 
						|
          true,
 | 
						|
          (double? progress) {
 | 
						|
            notificationsProvider.notify(
 | 
						|
              DownloadNotification(fileUrl.key, progress?.ceil() ?? 0),
 | 
						|
            );
 | 
						|
          },
 | 
						|
          downloadPath,
 | 
						|
          headers: await SourceProvider()
 | 
						|
              .getSource(app.url, overrideSource: app.overrideSource)
 | 
						|
              .getRequestHeaders(
 | 
						|
                app.additionalSettings,
 | 
						|
                forAPKDownload: fileUrl.key.endsWith('.apk') ? true : false,
 | 
						|
              ),
 | 
						|
          useExisting: false,
 | 
						|
          allowInsecure: app.additionalSettings['allowInsecure'] == true,
 | 
						|
          logs: logs,
 | 
						|
        );
 | 
						|
        notificationsProvider.notify(
 | 
						|
          DownloadedNotification(fileUrl.key, fileUrl.value),
 | 
						|
        );
 | 
						|
      } catch (e) {
 | 
						|
        errors.add(fileUrl.key, e);
 | 
						|
      } finally {
 | 
						|
        notificationsProvider.cancel(DownloadNotification(fileUrl.key, 0).id);
 | 
						|
      }
 | 
						|
    }
 | 
						|
 | 
						|
    if (forceParallelDownloads || !settingsProvider.parallelDownloads) {
 | 
						|
      for (var urlWithApp in filesToDownload) {
 | 
						|
        await downloadFn(urlWithApp.key, urlWithApp.value);
 | 
						|
      }
 | 
						|
    } else {
 | 
						|
      await Future.wait(
 | 
						|
        filesToDownload.map(
 | 
						|
          (urlWithApp) => downloadFn(urlWithApp.key, urlWithApp.value),
 | 
						|
        ),
 | 
						|
      );
 | 
						|
    }
 | 
						|
    if (errors.idsByErrorString.isNotEmpty) {
 | 
						|
      throw errors;
 | 
						|
    }
 | 
						|
    return downloadedIds;
 | 
						|
  }
 | 
						|
 | 
						|
  Future<Directory> getAppsDir() async {
 | 
						|
    Directory appsDir = Directory(
 | 
						|
      '${(await getExternalStorageDirectory())!.path}/app_data',
 | 
						|
    );
 | 
						|
    if (!appsDir.existsSync()) {
 | 
						|
      appsDir.createSync();
 | 
						|
    }
 | 
						|
    return appsDir;
 | 
						|
  }
 | 
						|
 | 
						|
  bool isVersionDetectionPossible(AppInMemory? app) {
 | 
						|
    if (app?.app == null) {
 | 
						|
      return false;
 | 
						|
    }
 | 
						|
    var source = SourceProvider().getSource(
 | 
						|
      app!.app.url,
 | 
						|
      overrideSource: app.app.overrideSource,
 | 
						|
    );
 | 
						|
    var naiveStandardVersionDetection =
 | 
						|
        app.app.additionalSettings['naiveStandardVersionDetection'] == true ||
 | 
						|
        source.naiveStandardVersionDetection;
 | 
						|
    String? realInstalledVersion =
 | 
						|
        app.app.additionalSettings['useVersionCodeAsOSVersion'] == true
 | 
						|
        ? app.installedInfo?.versionCode.toString()
 | 
						|
        : app.installedInfo?.versionName;
 | 
						|
    bool isHTMLWithNoVersionDetection =
 | 
						|
        (source.runtimeType == HTML().runtimeType &&
 | 
						|
        (app.app.additionalSettings['versionExtractionRegEx'] as String?)
 | 
						|
                ?.isNotEmpty !=
 | 
						|
            true);
 | 
						|
    bool isDirectAPKLink = source.runtimeType == DirectAPKLink().runtimeType;
 | 
						|
    return app.app.additionalSettings['trackOnly'] != true &&
 | 
						|
        app.app.additionalSettings['releaseDateAsVersion'] != true &&
 | 
						|
        !isHTMLWithNoVersionDetection &&
 | 
						|
        !isDirectAPKLink &&
 | 
						|
        realInstalledVersion != null &&
 | 
						|
        app.app.installedVersion != null &&
 | 
						|
        (reconcileVersionDifferences(
 | 
						|
                  realInstalledVersion,
 | 
						|
                  app.app.installedVersion!,
 | 
						|
                ) !=
 | 
						|
                null ||
 | 
						|
            naiveStandardVersionDetection);
 | 
						|
  }
 | 
						|
 | 
						|
  // Given an App and it's on-device info...
 | 
						|
  // Reconcile unexpected differences between its reported installed version, real installed version, and reported latest version
 | 
						|
  App? getCorrectedInstallStatusAppIfPossible(
 | 
						|
    App app,
 | 
						|
    PackageInfo? installedInfo,
 | 
						|
  ) {
 | 
						|
    var modded = false;
 | 
						|
    var trackOnly = app.additionalSettings['trackOnly'] == true;
 | 
						|
    var versionDetectionIsStandard =
 | 
						|
        app.additionalSettings['versionDetection'] == true;
 | 
						|
    var naiveStandardVersionDetection =
 | 
						|
        app.additionalSettings['naiveStandardVersionDetection'] == true ||
 | 
						|
        SourceProvider()
 | 
						|
            .getSource(app.url, overrideSource: app.overrideSource)
 | 
						|
            .naiveStandardVersionDetection;
 | 
						|
    String? realInstalledVersion =
 | 
						|
        app.additionalSettings['useVersionCodeAsOSVersion'] == true
 | 
						|
        ? installedInfo?.versionCode.toString()
 | 
						|
        : installedInfo?.versionName;
 | 
						|
    // FIRST, COMPARE THE APP'S REPORTED AND REAL INSTALLED VERSIONS, WHERE ONE IS NULL
 | 
						|
    if (installedInfo == null && app.installedVersion != null && !trackOnly) {
 | 
						|
      // App says it's installed but isn't really (and isn't track only) - set to not installed
 | 
						|
      app.installedVersion = null;
 | 
						|
      modded = true;
 | 
						|
    } else if (realInstalledVersion != null && app.installedVersion == null) {
 | 
						|
      // App says it's not installed but really is - set to installed and use real package versionName (or versionCode if chosen)
 | 
						|
      app.installedVersion = realInstalledVersion;
 | 
						|
      modded = true;
 | 
						|
    }
 | 
						|
    // SECOND, RECONCILE DIFFERENCES BETWEEN THE APP'S REPORTED AND REAL INSTALLED VERSIONS, WHERE NEITHER IS NULL
 | 
						|
    if (realInstalledVersion != null &&
 | 
						|
        realInstalledVersion != app.installedVersion &&
 | 
						|
        versionDetectionIsStandard) {
 | 
						|
      // App's reported version and real version don't match (and it uses standard version detection)
 | 
						|
      // If they share a standard format (and are still different under it), update the reported version accordingly
 | 
						|
      var correctedInstalledVersion = reconcileVersionDifferences(
 | 
						|
        realInstalledVersion,
 | 
						|
        app.installedVersion!,
 | 
						|
      );
 | 
						|
      if (correctedInstalledVersion?.key == false) {
 | 
						|
        app.installedVersion = correctedInstalledVersion!.value;
 | 
						|
        modded = true;
 | 
						|
      } else if (naiveStandardVersionDetection) {
 | 
						|
        app.installedVersion = realInstalledVersion;
 | 
						|
        modded = true;
 | 
						|
      }
 | 
						|
    }
 | 
						|
    // THIRD, RECONCILE THE APP'S REPORTED INSTALLED AND LATEST VERSIONS
 | 
						|
    if (app.installedVersion != null &&
 | 
						|
        app.installedVersion != app.latestVersion &&
 | 
						|
        versionDetectionIsStandard) {
 | 
						|
      // App's reported installed and latest versions don't match (and it uses standard version detection)
 | 
						|
      // If they share a standard format, make sure the App's reported installed version uses that format
 | 
						|
      var correctedInstalledVersion = reconcileVersionDifferences(
 | 
						|
        app.installedVersion!,
 | 
						|
        app.latestVersion,
 | 
						|
      );
 | 
						|
      if (correctedInstalledVersion?.key == true) {
 | 
						|
        app.installedVersion = correctedInstalledVersion!.value;
 | 
						|
        modded = true;
 | 
						|
      }
 | 
						|
    }
 | 
						|
    // FOURTH, DISABLE VERSION DETECTION IF ENABLED AND THE REPORTED/REAL INSTALLED VERSIONS ARE NOT STANDARDIZED
 | 
						|
    if (installedInfo != null &&
 | 
						|
        versionDetectionIsStandard &&
 | 
						|
        !isVersionDetectionPossible(
 | 
						|
          AppInMemory(app, null, installedInfo, null),
 | 
						|
        )) {
 | 
						|
      app.additionalSettings['versionDetection'] = false;
 | 
						|
      app.installedVersion = app.latestVersion;
 | 
						|
      logs.add('Could not reconcile version formats for: ${app.id}');
 | 
						|
      modded = true;
 | 
						|
    }
 | 
						|
 | 
						|
    return modded ? app : null;
 | 
						|
  }
 | 
						|
 | 
						|
  MapEntry<bool, String>? reconcileVersionDifferences(
 | 
						|
    String templateVersion,
 | 
						|
    String comparisonVersion,
 | 
						|
  ) {
 | 
						|
    // Returns null if the versions don't share a common standard format
 | 
						|
    // Returns <true, comparisonVersion> if they share a common format and are equal
 | 
						|
    // Returns <false, templateVersion> if they share a common format but are not equal
 | 
						|
    // templateVersion must fully match a standard format, while comparisonVersion can have a substring match
 | 
						|
    var templateVersionFormats = findStandardFormatsForVersion(
 | 
						|
      templateVersion,
 | 
						|
      true,
 | 
						|
    );
 | 
						|
    var comparisonVersionFormats = findStandardFormatsForVersion(
 | 
						|
      comparisonVersion,
 | 
						|
      true,
 | 
						|
    );
 | 
						|
    if (comparisonVersionFormats.isEmpty) {
 | 
						|
      comparisonVersionFormats = findStandardFormatsForVersion(
 | 
						|
        comparisonVersion,
 | 
						|
        false,
 | 
						|
      );
 | 
						|
    }
 | 
						|
    var commonStandardFormats = templateVersionFormats.intersection(
 | 
						|
      comparisonVersionFormats,
 | 
						|
    );
 | 
						|
    if (commonStandardFormats.isEmpty) {
 | 
						|
      return null;
 | 
						|
    }
 | 
						|
    for (String pattern in commonStandardFormats) {
 | 
						|
      if (doStringsMatchUnderRegEx(
 | 
						|
        pattern,
 | 
						|
        comparisonVersion,
 | 
						|
        templateVersion,
 | 
						|
      )) {
 | 
						|
        return MapEntry(true, comparisonVersion);
 | 
						|
      }
 | 
						|
    }
 | 
						|
    return MapEntry(false, templateVersion);
 | 
						|
  }
 | 
						|
 | 
						|
  bool doStringsMatchUnderRegEx(String pattern, String value1, String value2) {
 | 
						|
    var r = RegExp(pattern);
 | 
						|
    var m1 = r.firstMatch(value1);
 | 
						|
    var m2 = r.firstMatch(value2);
 | 
						|
    return m1 != null && m2 != null
 | 
						|
        ? value1.substring(m1.start, m1.end) ==
 | 
						|
              value2.substring(m2.start, m2.end)
 | 
						|
        : false;
 | 
						|
  }
 | 
						|
 | 
						|
  Future<void> loadApps({String? singleId}) async {
 | 
						|
    while (loadingApps) {
 | 
						|
      await Future.delayed(const Duration(microseconds: 1));
 | 
						|
    }
 | 
						|
    loadingApps = true;
 | 
						|
    notifyListeners();
 | 
						|
    var sp = SourceProvider();
 | 
						|
    List<List<String>> errors = [];
 | 
						|
    var installedAppsData = await getAllInstalledInfo();
 | 
						|
    List<String> removedAppIds = [];
 | 
						|
    await Future.wait(
 | 
						|
      (await getAppsDir()) // Parse Apps from JSON
 | 
						|
          .listSync()
 | 
						|
          .map((item) async {
 | 
						|
            App? app;
 | 
						|
            if (item.path.toLowerCase().endsWith('.json') &&
 | 
						|
                (singleId == null ||
 | 
						|
                    item.path.split('/').last.toLowerCase() ==
 | 
						|
                        '${singleId.toLowerCase()}.json')) {
 | 
						|
              try {
 | 
						|
                app = App.fromJson(
 | 
						|
                  jsonDecode(File(item.path).readAsStringSync()),
 | 
						|
                );
 | 
						|
              } catch (err) {
 | 
						|
                if (err is FormatException) {
 | 
						|
                  logs.add(
 | 
						|
                    'Corrupt JSON when loading App (will be ignored): $e',
 | 
						|
                  );
 | 
						|
                  item.renameSync('${item.path}.corrupt');
 | 
						|
                } else {
 | 
						|
                  rethrow;
 | 
						|
                }
 | 
						|
              }
 | 
						|
            }
 | 
						|
            if (app != null) {
 | 
						|
              // Save the app to the in-memory list without grabbing any OS info first
 | 
						|
              apps.update(
 | 
						|
                app.id,
 | 
						|
                (value) => AppInMemory(
 | 
						|
                  app!,
 | 
						|
                  value.downloadProgress,
 | 
						|
                  value.installedInfo,
 | 
						|
                  value.icon,
 | 
						|
                ),
 | 
						|
                ifAbsent: () => AppInMemory(app!, null, null, null),
 | 
						|
              );
 | 
						|
              notifyListeners();
 | 
						|
              try {
 | 
						|
                // Try getting the app's source to ensure no invalid apps get loaded
 | 
						|
                sp.getSource(app.url, overrideSource: app.overrideSource);
 | 
						|
                // If the app is installed, grab its OS data and reconcile install statuses
 | 
						|
                PackageInfo? installedInfo;
 | 
						|
                try {
 | 
						|
                  installedInfo = installedAppsData.firstWhere(
 | 
						|
                    (i) => i.packageName == app!.id,
 | 
						|
                  );
 | 
						|
                } catch (e) {
 | 
						|
                  // If the app isn't installed the above throws an error
 | 
						|
                }
 | 
						|
                // Reconcile differences between the installed and recorded install info
 | 
						|
                var moddedApp = getCorrectedInstallStatusAppIfPossible(
 | 
						|
                  app,
 | 
						|
                  installedInfo,
 | 
						|
                );
 | 
						|
                if (moddedApp != null) {
 | 
						|
                  app = moddedApp;
 | 
						|
                  // Note the app ID if it was uninstalled externally
 | 
						|
                  if (moddedApp.installedVersion == null) {
 | 
						|
                    removedAppIds.add(moddedApp.id);
 | 
						|
                  }
 | 
						|
                }
 | 
						|
                // Update the app in memory with install info and corrections
 | 
						|
                apps.update(
 | 
						|
                  app.id,
 | 
						|
                  (value) => AppInMemory(
 | 
						|
                    app!,
 | 
						|
                    value.downloadProgress,
 | 
						|
                    installedInfo,
 | 
						|
                    value.icon,
 | 
						|
                  ),
 | 
						|
                  ifAbsent: () => AppInMemory(app!, null, installedInfo, null),
 | 
						|
                );
 | 
						|
                notifyListeners();
 | 
						|
              } catch (e) {
 | 
						|
                errors.add([app!.id, app.finalName, e.toString()]);
 | 
						|
              }
 | 
						|
            }
 | 
						|
          }),
 | 
						|
    );
 | 
						|
    if (errors.isNotEmpty) {
 | 
						|
      removeApps(errors.map((e) => e[0]).toList());
 | 
						|
      NotificationsProvider().notify(
 | 
						|
        AppsRemovedNotification(errors.map((e) => [e[1], e[2]]).toList()),
 | 
						|
      );
 | 
						|
    }
 | 
						|
    // Delete externally uninstalled Apps if needed
 | 
						|
    if (removedAppIds.isNotEmpty) {
 | 
						|
      if (removedAppIds.isNotEmpty) {
 | 
						|
        if (settingsProvider.removeOnExternalUninstall) {
 | 
						|
          await removeApps(removedAppIds);
 | 
						|
        }
 | 
						|
      }
 | 
						|
    }
 | 
						|
    loadingApps = false;
 | 
						|
    notifyListeners();
 | 
						|
  }
 | 
						|
 | 
						|
  Future<void> updateAppIcon(String? appId, {bool ignoreCache = false}) async {
 | 
						|
    if (apps[appId]?.icon == null) {
 | 
						|
      var cachedIcon = File('${iconsCacheDir.path}/$appId.png');
 | 
						|
      var alreadyCached = cachedIcon.existsSync() && !ignoreCache;
 | 
						|
      var icon = alreadyCached
 | 
						|
          ? (await cachedIcon.readAsBytes())
 | 
						|
          : (await apps[appId]?.installedInfo?.applicationInfo?.getAppIcon());
 | 
						|
      if (icon != null && !alreadyCached) {
 | 
						|
        cachedIcon.writeAsBytes(icon.toList());
 | 
						|
      }
 | 
						|
      if (icon != null) {
 | 
						|
        apps.update(
 | 
						|
          apps[appId]!.app.id,
 | 
						|
          (value) => AppInMemory(
 | 
						|
            apps[appId]!.app,
 | 
						|
            value.downloadProgress,
 | 
						|
            value.installedInfo,
 | 
						|
            icon,
 | 
						|
          ),
 | 
						|
          ifAbsent: () => AppInMemory(
 | 
						|
            apps[appId]!.app,
 | 
						|
            null,
 | 
						|
            apps[appId]?.installedInfo,
 | 
						|
            icon,
 | 
						|
          ),
 | 
						|
        );
 | 
						|
        notifyListeners();
 | 
						|
      }
 | 
						|
    }
 | 
						|
  }
 | 
						|
 | 
						|
  Future<void> saveApps(
 | 
						|
    List<App> apps, {
 | 
						|
    bool attemptToCorrectInstallStatus = true,
 | 
						|
    bool onlyIfExists = true,
 | 
						|
  }) async {
 | 
						|
    attemptToCorrectInstallStatus = attemptToCorrectInstallStatus;
 | 
						|
    await Future.wait(
 | 
						|
      apps.map((a) async {
 | 
						|
        var app = a.deepCopy();
 | 
						|
        PackageInfo? info = await getInstalledInfo(app.id);
 | 
						|
        var icon = await info?.applicationInfo?.getAppIcon();
 | 
						|
        app.name = await (info?.applicationInfo?.getAppLabel()) ?? app.name;
 | 
						|
        if (attemptToCorrectInstallStatus) {
 | 
						|
          app = getCorrectedInstallStatusAppIfPossible(app, info) ?? app;
 | 
						|
        }
 | 
						|
        if (!onlyIfExists || this.apps.containsKey(app.id)) {
 | 
						|
          String filePath = '${(await getAppsDir()).path}/${app.id}.json';
 | 
						|
          File(
 | 
						|
            '$filePath.tmp',
 | 
						|
          ).writeAsStringSync(jsonEncode(app.toJson())); // #2089
 | 
						|
          File('$filePath.tmp').renameSync(filePath);
 | 
						|
        }
 | 
						|
        try {
 | 
						|
          this.apps.update(
 | 
						|
            app.id,
 | 
						|
            (value) => AppInMemory(app, value.downloadProgress, info, icon),
 | 
						|
            ifAbsent: onlyIfExists
 | 
						|
                ? null
 | 
						|
                : () => AppInMemory(app, null, info, icon),
 | 
						|
          );
 | 
						|
        } catch (e) {
 | 
						|
          if (e is! ArgumentError || e.name != 'key') {
 | 
						|
            rethrow;
 | 
						|
          }
 | 
						|
        }
 | 
						|
      }),
 | 
						|
    );
 | 
						|
    notifyListeners();
 | 
						|
    export(isAuto: true);
 | 
						|
  }
 | 
						|
 | 
						|
  Future<void> removeApps(List<String> appIds) async {
 | 
						|
    var apkFiles = APKDir.listSync();
 | 
						|
    await Future.wait(
 | 
						|
      appIds.map((appId) async {
 | 
						|
        File file = File('${(await getAppsDir()).path}/$appId.json');
 | 
						|
        if (file.existsSync()) {
 | 
						|
          deleteFile(file);
 | 
						|
        }
 | 
						|
        apkFiles
 | 
						|
            .where(
 | 
						|
              (element) => element.path.split('/').last.startsWith('$appId-'),
 | 
						|
            )
 | 
						|
            .forEach((element) {
 | 
						|
              element.delete(recursive: true);
 | 
						|
            });
 | 
						|
        if (apps.containsKey(appId)) {
 | 
						|
          apps.remove(appId);
 | 
						|
        }
 | 
						|
      }),
 | 
						|
    );
 | 
						|
    if (appIds.isNotEmpty) {
 | 
						|
      notifyListeners();
 | 
						|
      export(isAuto: true);
 | 
						|
    }
 | 
						|
  }
 | 
						|
 | 
						|
  Future<bool> removeAppsWithModal(BuildContext context, List<App> apps) async {
 | 
						|
    var showUninstallOption = apps
 | 
						|
        .where(
 | 
						|
          (a) =>
 | 
						|
              a.installedVersion != null &&
 | 
						|
              a.additionalSettings['trackOnly'] != true,
 | 
						|
        )
 | 
						|
        .isNotEmpty;
 | 
						|
    var values = await showDialog(
 | 
						|
      context: context,
 | 
						|
      builder: (BuildContext ctx) {
 | 
						|
        return GeneratedFormModal(
 | 
						|
          primaryActionColour: Theme.of(context).colorScheme.error,
 | 
						|
          title: plural('removeAppQuestion', apps.length),
 | 
						|
          items: !showUninstallOption
 | 
						|
              ? []
 | 
						|
              : [
 | 
						|
                  [
 | 
						|
                    GeneratedFormSwitch(
 | 
						|
                      'rmAppEntry',
 | 
						|
                      label: tr('removeFromObtainium'),
 | 
						|
                      defaultValue: true,
 | 
						|
                    ),
 | 
						|
                  ],
 | 
						|
                  [
 | 
						|
                    GeneratedFormSwitch(
 | 
						|
                      'uninstallApp',
 | 
						|
                      label: tr('uninstallFromDevice'),
 | 
						|
                    ),
 | 
						|
                  ],
 | 
						|
                ],
 | 
						|
          initValid: true,
 | 
						|
        );
 | 
						|
      },
 | 
						|
    );
 | 
						|
    if (values != null) {
 | 
						|
      bool uninstall = values['uninstallApp'] == true && showUninstallOption;
 | 
						|
      bool remove = values['rmAppEntry'] == true || !showUninstallOption;
 | 
						|
      if (uninstall) {
 | 
						|
        for (var i = 0; i < apps.length; i++) {
 | 
						|
          if (apps[i].installedVersion != null) {
 | 
						|
            uninstallApp(apps[i].id);
 | 
						|
            apps[i].installedVersion = null;
 | 
						|
          }
 | 
						|
        }
 | 
						|
        await saveApps(apps, attemptToCorrectInstallStatus: false);
 | 
						|
      }
 | 
						|
      if (remove) {
 | 
						|
        await removeApps(apps.map((e) => e.id).toList());
 | 
						|
      }
 | 
						|
      return uninstall || remove;
 | 
						|
    }
 | 
						|
    return false;
 | 
						|
  }
 | 
						|
 | 
						|
  Future<void> openAppSettings(String appId) async {
 | 
						|
    final AndroidIntent intent = AndroidIntent(
 | 
						|
      action: 'action_application_details_settings',
 | 
						|
      data: 'package:$appId',
 | 
						|
    );
 | 
						|
    await intent.launch();
 | 
						|
  }
 | 
						|
 | 
						|
  void addMissingCategories(SettingsProvider settingsProvider) {
 | 
						|
    var cats = settingsProvider.categories;
 | 
						|
    apps.forEach((key, value) {
 | 
						|
      for (var c in value.app.categories) {
 | 
						|
        if (!cats.containsKey(c)) {
 | 
						|
          cats[c] = generateRandomLightColor().value;
 | 
						|
        }
 | 
						|
      }
 | 
						|
    });
 | 
						|
    settingsProvider.setCategories(cats, appsProvider: this);
 | 
						|
  }
 | 
						|
 | 
						|
  Future<App?> checkUpdate(String appId) async {
 | 
						|
    App? currentApp = apps[appId]!.app;
 | 
						|
    SourceProvider sourceProvider = SourceProvider();
 | 
						|
    App newApp = await sourceProvider.getApp(
 | 
						|
      sourceProvider.getSource(
 | 
						|
        currentApp.url,
 | 
						|
        overrideSource: currentApp.overrideSource,
 | 
						|
      ),
 | 
						|
      currentApp.url,
 | 
						|
      currentApp.additionalSettings,
 | 
						|
      currentApp: currentApp,
 | 
						|
    );
 | 
						|
    if (currentApp.preferredApkIndex < newApp.apkUrls.length) {
 | 
						|
      newApp.preferredApkIndex = currentApp.preferredApkIndex;
 | 
						|
    }
 | 
						|
    await saveApps([newApp]);
 | 
						|
    return newApp.latestVersion != currentApp.latestVersion ? newApp : null;
 | 
						|
  }
 | 
						|
 | 
						|
  List<String> getAppsSortedByUpdateCheckTime({
 | 
						|
    DateTime? ignoreAppsCheckedAfter,
 | 
						|
    bool onlyCheckInstalledOrTrackOnlyApps = false,
 | 
						|
  }) {
 | 
						|
    List<String> appIds = apps.values
 | 
						|
        .where(
 | 
						|
          (app) =>
 | 
						|
              app.app.lastUpdateCheck == null ||
 | 
						|
              ignoreAppsCheckedAfter == null ||
 | 
						|
              app.app.lastUpdateCheck!.isBefore(ignoreAppsCheckedAfter),
 | 
						|
        )
 | 
						|
        .where((app) {
 | 
						|
          if (!onlyCheckInstalledOrTrackOnlyApps) {
 | 
						|
            return true;
 | 
						|
          } else {
 | 
						|
            return app.app.installedVersion != null ||
 | 
						|
                app.app.additionalSettings['trackOnly'] == true;
 | 
						|
          }
 | 
						|
        })
 | 
						|
        .map((e) => e.app.id)
 | 
						|
        .toList();
 | 
						|
    appIds.sort(
 | 
						|
      (a, b) =>
 | 
						|
          (apps[a]!.app.lastUpdateCheck ??
 | 
						|
                  DateTime.fromMicrosecondsSinceEpoch(0))
 | 
						|
              .compareTo(
 | 
						|
                apps[b]!.app.lastUpdateCheck ??
 | 
						|
                    DateTime.fromMicrosecondsSinceEpoch(0),
 | 
						|
              ),
 | 
						|
    );
 | 
						|
    return appIds;
 | 
						|
  }
 | 
						|
 | 
						|
  Future<List<App>> checkUpdates({
 | 
						|
    DateTime? ignoreAppsCheckedAfter,
 | 
						|
    bool throwErrorsForRetry = false,
 | 
						|
    List<String>? specificIds,
 | 
						|
    SettingsProvider? sp,
 | 
						|
  }) async {
 | 
						|
    SettingsProvider settingsProvider = sp ?? this.settingsProvider;
 | 
						|
    List<App> updates = [];
 | 
						|
    MultiAppMultiError errors = MultiAppMultiError();
 | 
						|
    if (!gettingUpdates) {
 | 
						|
      gettingUpdates = true;
 | 
						|
      try {
 | 
						|
        List<String> appIds = getAppsSortedByUpdateCheckTime(
 | 
						|
          ignoreAppsCheckedAfter: ignoreAppsCheckedAfter,
 | 
						|
          onlyCheckInstalledOrTrackOnlyApps:
 | 
						|
              settingsProvider.onlyCheckInstalledOrTrackOnlyApps,
 | 
						|
        );
 | 
						|
        if (specificIds != null) {
 | 
						|
          appIds = appIds.where((aId) => specificIds.contains(aId)).toList();
 | 
						|
        }
 | 
						|
        await Future.wait(
 | 
						|
          appIds.map((appId) async {
 | 
						|
            App? newApp;
 | 
						|
            try {
 | 
						|
              newApp = await checkUpdate(appId);
 | 
						|
            } catch (e) {
 | 
						|
              if ((e is RateLimitError || e is SocketException) &&
 | 
						|
                  throwErrorsForRetry) {
 | 
						|
                rethrow;
 | 
						|
              }
 | 
						|
              errors.add(appId, e, appName: apps[appId]?.name);
 | 
						|
            }
 | 
						|
            if (newApp != null) {
 | 
						|
              updates.add(newApp);
 | 
						|
            }
 | 
						|
          }),
 | 
						|
          eagerError: true,
 | 
						|
        );
 | 
						|
      } finally {
 | 
						|
        gettingUpdates = false;
 | 
						|
      }
 | 
						|
    }
 | 
						|
    if (errors.idsByErrorString.isNotEmpty) {
 | 
						|
      var res = <String, dynamic>{};
 | 
						|
      res['errors'] = errors;
 | 
						|
      res['updates'] = updates;
 | 
						|
      throw res;
 | 
						|
    }
 | 
						|
    return updates;
 | 
						|
  }
 | 
						|
 | 
						|
  List<String> findExistingUpdates({
 | 
						|
    bool installedOnly = false,
 | 
						|
    bool nonInstalledOnly = false,
 | 
						|
  }) {
 | 
						|
    List<String> updateAppIds = [];
 | 
						|
    List<String> appIds = apps.keys.toList();
 | 
						|
    for (int i = 0; i < appIds.length; i++) {
 | 
						|
      App? app = apps[appIds[i]]!.app;
 | 
						|
      if (app.installedVersion != app.latestVersion &&
 | 
						|
          (!installedOnly || !nonInstalledOnly)) {
 | 
						|
        if ((app.installedVersion == null &&
 | 
						|
                (nonInstalledOnly || !installedOnly) ||
 | 
						|
            (app.installedVersion != null &&
 | 
						|
                (installedOnly || !nonInstalledOnly)))) {
 | 
						|
          updateAppIds.add(app.id);
 | 
						|
        }
 | 
						|
      }
 | 
						|
    }
 | 
						|
    return updateAppIds;
 | 
						|
  }
 | 
						|
 | 
						|
  Map<String, dynamic> generateExportJSON({
 | 
						|
    List<String>? appIds,
 | 
						|
    int? overrideExportSettings,
 | 
						|
  }) {
 | 
						|
    Map<String, dynamic> finalExport = {};
 | 
						|
    finalExport['apps'] = apps.values
 | 
						|
        .where((e) {
 | 
						|
          if (appIds == null) {
 | 
						|
            return true;
 | 
						|
          } else {
 | 
						|
            return appIds.contains(e.app.id);
 | 
						|
          }
 | 
						|
        })
 | 
						|
        .map((e) => e.app.toJson())
 | 
						|
        .toList();
 | 
						|
    int shouldExportSettings = settingsProvider.exportSettings;
 | 
						|
    if (overrideExportSettings != null) {
 | 
						|
      shouldExportSettings = overrideExportSettings;
 | 
						|
    }
 | 
						|
    if (shouldExportSettings > 0) {
 | 
						|
      var settingsValueKeys = settingsProvider.prefs?.getKeys();
 | 
						|
      if (shouldExportSettings < 2) {
 | 
						|
        settingsValueKeys?.removeWhere((k) => k.endsWith('-creds'));
 | 
						|
      }
 | 
						|
      finalExport['settings'] = Map<String, Object?>.fromEntries(
 | 
						|
        (settingsValueKeys
 | 
						|
                ?.map((key) => MapEntry(key, settingsProvider.prefs?.get(key)))
 | 
						|
                .toList()) ??
 | 
						|
            [],
 | 
						|
      );
 | 
						|
    }
 | 
						|
    return finalExport;
 | 
						|
  }
 | 
						|
 | 
						|
  Future<String?> export({
 | 
						|
    bool pickOnly = false,
 | 
						|
    isAuto = false,
 | 
						|
    SettingsProvider? sp,
 | 
						|
  }) async {
 | 
						|
    SettingsProvider settingsProvider = sp ?? this.settingsProvider;
 | 
						|
    var exportDir = await settingsProvider.getExportDir();
 | 
						|
    if (isAuto) {
 | 
						|
      if (settingsProvider.autoExportOnChanges != true) {
 | 
						|
        return null;
 | 
						|
      }
 | 
						|
      if (exportDir == null) {
 | 
						|
        return null;
 | 
						|
      }
 | 
						|
      var files = await saf
 | 
						|
          .listFiles(exportDir, columns: [saf.DocumentFileColumn.id])
 | 
						|
          .where((f) => f.uri.pathSegments.last.endsWith('-auto.json'))
 | 
						|
          .toList();
 | 
						|
      if (files.isNotEmpty) {
 | 
						|
        for (var f in files) {
 | 
						|
          saf.delete(f.uri);
 | 
						|
        }
 | 
						|
      }
 | 
						|
    }
 | 
						|
    if (exportDir == null || pickOnly) {
 | 
						|
      await settingsProvider.pickExportDir();
 | 
						|
      exportDir = await settingsProvider.getExportDir();
 | 
						|
    }
 | 
						|
    if (exportDir == null) {
 | 
						|
      return null;
 | 
						|
    }
 | 
						|
    String? returnPath;
 | 
						|
    if (!pickOnly) {
 | 
						|
      var encoder = const JsonEncoder.withIndent("    ");
 | 
						|
      Map<String, dynamic> finalExport = generateExportJSON();
 | 
						|
      var result = await saf.createFile(
 | 
						|
        exportDir,
 | 
						|
        displayName:
 | 
						|
            '${tr('obtainiumExportHyphenatedLowercase')}-${DateTime.now().toIso8601String().replaceAll(':', '-')}${isAuto ? '-auto' : ''}.json',
 | 
						|
        mimeType: 'application/json',
 | 
						|
        bytes: Uint8List.fromList(utf8.encode(encoder.convert(finalExport))),
 | 
						|
      );
 | 
						|
      if (result == null) {
 | 
						|
        throw ObtainiumError(tr('unexpectedError'));
 | 
						|
      }
 | 
						|
      returnPath = exportDir.pathSegments
 | 
						|
          .join('/')
 | 
						|
          .replaceFirst('tree/primary:', '/');
 | 
						|
    }
 | 
						|
    return returnPath;
 | 
						|
  }
 | 
						|
 | 
						|
  Future<MapEntry<List<App>, bool>> import(String appsJSON) async {
 | 
						|
    var decodedJSON = jsonDecode(appsJSON);
 | 
						|
    var newFormat = decodedJSON is! List;
 | 
						|
    List<App> importedApps =
 | 
						|
        ((newFormat ? decodedJSON['apps'] : decodedJSON) as List<dynamic>)
 | 
						|
            .map((e) => App.fromJson(e))
 | 
						|
            .toList();
 | 
						|
    while (loadingApps) {
 | 
						|
      await Future.delayed(const Duration(microseconds: 1));
 | 
						|
    }
 | 
						|
    for (App a in importedApps) {
 | 
						|
      var installedInfo = await getInstalledInfo(a.id, printErr: false);
 | 
						|
      a.installedVersion =
 | 
						|
          a.additionalSettings['useVersionCodeAsOSVersion'] == true
 | 
						|
          ? installedInfo?.versionCode.toString()
 | 
						|
          : installedInfo?.versionName;
 | 
						|
    }
 | 
						|
    await saveApps(importedApps, onlyIfExists: false);
 | 
						|
    notifyListeners();
 | 
						|
    if (newFormat && decodedJSON['settings'] != null) {
 | 
						|
      var settingsMap = decodedJSON['settings'] as Map<String, Object?>;
 | 
						|
      settingsMap.forEach((key, value) {
 | 
						|
        if (value is int) {
 | 
						|
          settingsProvider.prefs?.setInt(key, value);
 | 
						|
        } else if (value is double) {
 | 
						|
          settingsProvider.prefs?.setDouble(key, value);
 | 
						|
        } else if (value is bool) {
 | 
						|
          settingsProvider.prefs?.setBool(key, value);
 | 
						|
        } else if (value is List) {
 | 
						|
          settingsProvider.prefs?.setStringList(
 | 
						|
            key,
 | 
						|
            value.map((e) => e as String).toList(),
 | 
						|
          );
 | 
						|
        } else {
 | 
						|
          settingsProvider.prefs?.setString(key, value as String);
 | 
						|
        }
 | 
						|
      });
 | 
						|
    }
 | 
						|
    return MapEntry<List<App>, bool>(
 | 
						|
      importedApps,
 | 
						|
      newFormat && decodedJSON['settings'] != null,
 | 
						|
    );
 | 
						|
  }
 | 
						|
 | 
						|
  @override
 | 
						|
  void dispose() {
 | 
						|
    foregroundSubscription?.cancel();
 | 
						|
    super.dispose();
 | 
						|
  }
 | 
						|
 | 
						|
  Future<List<List<String>>> addAppsByURL(
 | 
						|
    List<String> urls, {
 | 
						|
    AppSource? sourceOverride,
 | 
						|
  }) async {
 | 
						|
    List<dynamic> results = await SourceProvider().getAppsByURLNaive(
 | 
						|
      urls,
 | 
						|
      alreadyAddedUrls: apps.values.map((e) => e.app.url).toList(),
 | 
						|
      sourceOverride: sourceOverride,
 | 
						|
    );
 | 
						|
    List<App> pps = results[0];
 | 
						|
    Map<String, dynamic> errorsMap = results[1];
 | 
						|
    for (var app in pps) {
 | 
						|
      if (apps.containsKey(app.id)) {
 | 
						|
        errorsMap.addAll({app.id: tr('appAlreadyAdded')});
 | 
						|
      } else {
 | 
						|
        await saveApps([app], onlyIfExists: false);
 | 
						|
      }
 | 
						|
    }
 | 
						|
    List<List<String>> errors = errorsMap.keys
 | 
						|
        .map((e) => [e, errorsMap[e].toString()])
 | 
						|
        .toList();
 | 
						|
    return errors;
 | 
						|
  }
 | 
						|
}
 | 
						|
 | 
						|
class AppFilePicker extends StatefulWidget {
 | 
						|
  const AppFilePicker({
 | 
						|
    super.key,
 | 
						|
    required this.app,
 | 
						|
    this.initVal,
 | 
						|
    this.archs,
 | 
						|
    this.pickAnyAsset = false,
 | 
						|
  });
 | 
						|
 | 
						|
  final App app;
 | 
						|
  final MapEntry<String, String>? initVal;
 | 
						|
  final List<String>? archs;
 | 
						|
  final bool pickAnyAsset;
 | 
						|
 | 
						|
  @override
 | 
						|
  State<AppFilePicker> createState() => _AppFilePickerState();
 | 
						|
}
 | 
						|
 | 
						|
class _AppFilePickerState extends State<AppFilePicker> {
 | 
						|
  MapEntry<String, String>? fileUrl;
 | 
						|
 | 
						|
  @override
 | 
						|
  Widget build(BuildContext context) {
 | 
						|
    fileUrl ??= widget.initVal;
 | 
						|
    var urlsToSelectFrom = widget.app.apkUrls;
 | 
						|
    if (widget.pickAnyAsset) {
 | 
						|
      urlsToSelectFrom = [...urlsToSelectFrom, ...widget.app.otherAssetUrls];
 | 
						|
    }
 | 
						|
    return AlertDialog(
 | 
						|
      scrollable: true,
 | 
						|
      title: Text(
 | 
						|
        widget.pickAnyAsset
 | 
						|
            ? tr('selectX', args: [lowerCaseIfEnglish(tr('releaseAsset'))])
 | 
						|
            : tr('pickAnAPK'),
 | 
						|
      ),
 | 
						|
      content: Column(
 | 
						|
        children: [
 | 
						|
          urlsToSelectFrom.length > 1
 | 
						|
              ? Text(
 | 
						|
                  tr('appHasMoreThanOnePackage', args: [widget.app.finalName]),
 | 
						|
                )
 | 
						|
              : const SizedBox.shrink(),
 | 
						|
          const SizedBox(height: 16),
 | 
						|
          ...urlsToSelectFrom.map(
 | 
						|
            (u) => RadioListTile<String>(
 | 
						|
              title: Text(u.key),
 | 
						|
              value: u.value,
 | 
						|
              groupValue: fileUrl!.value,
 | 
						|
              onChanged: (String? val) {
 | 
						|
                setState(() {
 | 
						|
                  fileUrl = urlsToSelectFrom.where((e) => e.value == val).first;
 | 
						|
                });
 | 
						|
              },
 | 
						|
            ),
 | 
						|
          ),
 | 
						|
          if (widget.archs != null) const SizedBox(height: 16),
 | 
						|
          if (widget.archs != null)
 | 
						|
            Text(
 | 
						|
              widget.archs!.length == 1
 | 
						|
                  ? tr('deviceSupportsXArch', args: [widget.archs![0]])
 | 
						|
                  : tr('deviceSupportsFollowingArchs') +
 | 
						|
                        list2FriendlyString(
 | 
						|
                          widget.archs!.map((e) => '\'$e\'').toList(),
 | 
						|
                        ),
 | 
						|
              style: const TextStyle(fontStyle: FontStyle.italic, fontSize: 12),
 | 
						|
            ),
 | 
						|
        ],
 | 
						|
      ),
 | 
						|
      actions: [
 | 
						|
        TextButton(
 | 
						|
          onPressed: () {
 | 
						|
            Navigator.of(context).pop(null);
 | 
						|
          },
 | 
						|
          child: Text(tr('cancel')),
 | 
						|
        ),
 | 
						|
        TextButton(
 | 
						|
          onPressed: () {
 | 
						|
            HapticFeedback.selectionClick();
 | 
						|
            Navigator.of(context).pop(fileUrl);
 | 
						|
          },
 | 
						|
          child: Text(tr('continue')),
 | 
						|
        ),
 | 
						|
      ],
 | 
						|
    );
 | 
						|
  }
 | 
						|
}
 | 
						|
 | 
						|
class APKOriginWarningDialog extends StatefulWidget {
 | 
						|
  const APKOriginWarningDialog({
 | 
						|
    super.key,
 | 
						|
    required this.sourceUrl,
 | 
						|
    required this.apkUrl,
 | 
						|
  });
 | 
						|
 | 
						|
  final String sourceUrl;
 | 
						|
  final String apkUrl;
 | 
						|
 | 
						|
  @override
 | 
						|
  State<APKOriginWarningDialog> createState() => _APKOriginWarningDialogState();
 | 
						|
}
 | 
						|
 | 
						|
class _APKOriginWarningDialogState extends State<APKOriginWarningDialog> {
 | 
						|
  @override
 | 
						|
  Widget build(BuildContext context) {
 | 
						|
    return AlertDialog(
 | 
						|
      scrollable: true,
 | 
						|
      title: Text(tr('warning')),
 | 
						|
      content: Text(
 | 
						|
        tr(
 | 
						|
          'sourceIsXButPackageFromYPrompt',
 | 
						|
          args: [
 | 
						|
            Uri.parse(widget.sourceUrl).host,
 | 
						|
            Uri.parse(widget.apkUrl).host,
 | 
						|
          ],
 | 
						|
        ),
 | 
						|
      ),
 | 
						|
      actions: [
 | 
						|
        TextButton(
 | 
						|
          onPressed: () {
 | 
						|
            Navigator.of(context).pop(null);
 | 
						|
          },
 | 
						|
          child: Text(tr('cancel')),
 | 
						|
        ),
 | 
						|
        TextButton(
 | 
						|
          onPressed: () {
 | 
						|
            HapticFeedback.selectionClick();
 | 
						|
            Navigator.of(context).pop(true);
 | 
						|
          },
 | 
						|
          child: Text(tr('continue')),
 | 
						|
        ),
 | 
						|
      ],
 | 
						|
    );
 | 
						|
  }
 | 
						|
}
 | 
						|
 | 
						|
/// Background updater function
 | 
						|
///
 | 
						|
/// @param List<MapEntry<String, int>>? toCheck: The appIds to check for updates (with the number of previous attempts made per appid) (defaults to all apps)
 | 
						|
///
 | 
						|
/// @param List<String>? toInstall: The appIds to attempt to update (if empty - which is the default - all pending updates are taken)
 | 
						|
///
 | 
						|
/// When toCheck is empty, the function is in "install mode" (else it is in "update mode").
 | 
						|
/// In update mode, all apps in toCheck are checked for updates (in parallel).
 | 
						|
/// If an update is available and it cannot be installed silently, the user is notified of the available update.
 | 
						|
/// If there are any errors, we recursively call the same function with retry count for the relevant apps decremented (if zero, the user is notified).
 | 
						|
///
 | 
						|
/// Once all update checks are complete, the task is run again in install mode.
 | 
						|
/// In this mode, all pending silent updates are downloaded (in parallel) and installed in the background.
 | 
						|
/// If there is an error, the user is notified.
 | 
						|
///
 | 
						|
Future<void> bgUpdateCheck(String taskId, Map<String, dynamic>? params) async {
 | 
						|
  // ignore: avoid_print
 | 
						|
  print('BG task started $taskId: ${params.toString()}');
 | 
						|
  WidgetsFlutterBinding.ensureInitialized();
 | 
						|
  await EasyLocalization.ensureInitialized();
 | 
						|
  await loadTranslations();
 | 
						|
 | 
						|
  LogsProvider logs = LogsProvider();
 | 
						|
  NotificationsProvider notificationsProvider = NotificationsProvider();
 | 
						|
  AppsProvider appsProvider = AppsProvider(isBg: true);
 | 
						|
  await appsProvider.loadApps();
 | 
						|
 | 
						|
  int maxAttempts = 4;
 | 
						|
  int maxRetryWaitSeconds = 5;
 | 
						|
 | 
						|
  var netResult = await (Connectivity().checkConnectivity());
 | 
						|
  if (netResult.contains(ConnectivityResult.none) ||
 | 
						|
      netResult.isEmpty ||
 | 
						|
      (netResult.contains(ConnectivityResult.vpn) && netResult.length == 1)) {
 | 
						|
    logs.add('BG update task: No network.');
 | 
						|
    return;
 | 
						|
  }
 | 
						|
 | 
						|
  params ??= {};
 | 
						|
 | 
						|
  bool firstEverUpdateTask =
 | 
						|
      DateTime.fromMillisecondsSinceEpoch(
 | 
						|
        0,
 | 
						|
      ).compareTo(appsProvider.settingsProvider.lastCompletedBGCheckTime) ==
 | 
						|
      0;
 | 
						|
 | 
						|
  List<MapEntry<String, int>> toCheck = <MapEntry<String, int>>[
 | 
						|
    ...(params['toCheck']
 | 
						|
            ?.map(
 | 
						|
              (entry) => MapEntry<String, int>(
 | 
						|
                entry['key'] as String,
 | 
						|
                entry['value'] as int,
 | 
						|
              ),
 | 
						|
            )
 | 
						|
            .toList() ??
 | 
						|
        appsProvider
 | 
						|
            .getAppsSortedByUpdateCheckTime(
 | 
						|
              ignoreAppsCheckedAfter: params['toCheck'] == null
 | 
						|
                  ? firstEverUpdateTask
 | 
						|
                        ? null
 | 
						|
                        : appsProvider.settingsProvider.lastCompletedBGCheckTime
 | 
						|
                  : null,
 | 
						|
              onlyCheckInstalledOrTrackOnlyApps: appsProvider
 | 
						|
                  .settingsProvider
 | 
						|
                  .onlyCheckInstalledOrTrackOnlyApps,
 | 
						|
            )
 | 
						|
            .map((e) => MapEntry(e, 0))),
 | 
						|
  ];
 | 
						|
  List<MapEntry<String, int>> toInstall = <MapEntry<String, int>>[
 | 
						|
    ...(params['toInstall']
 | 
						|
            ?.map(
 | 
						|
              (entry) => MapEntry<String, int>(
 | 
						|
                entry['key'] as String,
 | 
						|
                entry['value'] as int,
 | 
						|
              ),
 | 
						|
            )
 | 
						|
            .toList() ??
 | 
						|
        (<List<MapEntry<String, int>>>[])),
 | 
						|
  ];
 | 
						|
 | 
						|
  var networkRestricted =
 | 
						|
      appsProvider.settingsProvider.bgUpdatesOnWiFiOnly &&
 | 
						|
      !netResult.contains(ConnectivityResult.wifi) &&
 | 
						|
      !netResult.contains(ConnectivityResult.ethernet);
 | 
						|
 | 
						|
  var chargingRestricted =
 | 
						|
      appsProvider.settingsProvider.bgUpdatesWhileChargingOnly &&
 | 
						|
      (await Battery().batteryState) != BatteryState.charging;
 | 
						|
 | 
						|
  if (networkRestricted) {
 | 
						|
    logs.add('BG update task: Network restriction in effect.');
 | 
						|
  }
 | 
						|
 | 
						|
  if (chargingRestricted) {
 | 
						|
    logs.add('BG update task: Charging restriction in effect.');
 | 
						|
  }
 | 
						|
 | 
						|
  if (toCheck.isNotEmpty) {
 | 
						|
    // Task is either in update mode or install mode
 | 
						|
    // If in update mode, we check for updates.
 | 
						|
    // We divide the results into 4 groups:
 | 
						|
    // - toNotify - Apps with updates that the user will be notified about (can't be silently installed)
 | 
						|
    // - toThrow - Apps with update check errors that the user will be notified about (no retry)
 | 
						|
    // After grouping the updates, we take care of toNotify and toThrow first
 | 
						|
    // Then we run the function again in install mode (toCheck is empty)
 | 
						|
 | 
						|
    var enoughTimePassed =
 | 
						|
        appsProvider.settingsProvider.updateInterval != 0 &&
 | 
						|
        appsProvider.settingsProvider.lastCompletedBGCheckTime
 | 
						|
            .add(
 | 
						|
              Duration(minutes: appsProvider.settingsProvider.updateInterval),
 | 
						|
            )
 | 
						|
            .isBefore(DateTime.now());
 | 
						|
    if (!enoughTimePassed) {
 | 
						|
      // ignore: avoid_print
 | 
						|
      print(
 | 
						|
        'BG update task: Too early for another check (last check was ${appsProvider.settingsProvider.lastCompletedBGCheckTime.toIso8601String()}, interval is ${appsProvider.settingsProvider.updateInterval}).',
 | 
						|
      );
 | 
						|
      return;
 | 
						|
    }
 | 
						|
 | 
						|
    logs.add('BG update task: Started (${toCheck.length}).');
 | 
						|
 | 
						|
    // Init. vars.
 | 
						|
    List<App> updates = []; // All updates found (silent and non-silent)
 | 
						|
    List<App> toNotify =
 | 
						|
        []; // All non-silent updates that the user will be notified about
 | 
						|
    List<MapEntry<String, int>> toRetry =
 | 
						|
        []; // All apps that got errors while checking
 | 
						|
    var retryAfterXSeconds = 0;
 | 
						|
    MultiAppMultiError?
 | 
						|
    errors; // All errors including those that will lead to a retry
 | 
						|
    MultiAppMultiError toThrow =
 | 
						|
        MultiAppMultiError(); // All errors that will not lead to a retry, just a notification
 | 
						|
    CheckingUpdatesNotification notif = CheckingUpdatesNotification(
 | 
						|
      plural('apps', toCheck.length),
 | 
						|
    ); // The notif. to show while checking
 | 
						|
 | 
						|
    try {
 | 
						|
      // Check for updates
 | 
						|
      notificationsProvider.notify(notif, cancelExisting: true);
 | 
						|
      updates = await appsProvider.checkUpdates(
 | 
						|
        specificIds: toCheck.map((e) => e.key).toList(),
 | 
						|
        sp: appsProvider.settingsProvider,
 | 
						|
      );
 | 
						|
    } catch (e) {
 | 
						|
      if (e is Map) {
 | 
						|
        updates = e['updates'];
 | 
						|
        errors = e['errors'];
 | 
						|
        errors!.rawErrors.forEach((key, err) {
 | 
						|
          logs.add(
 | 
						|
            'BG update task: Got error on checking for $key \'${err.toString()}\'.',
 | 
						|
          );
 | 
						|
 | 
						|
          var toCheckApp = toCheck.where((element) => element.key == key).first;
 | 
						|
          if (toCheckApp.value < maxAttempts) {
 | 
						|
            toRetry.add(MapEntry(toCheckApp.key, toCheckApp.value + 1));
 | 
						|
            // Next task interval is based on the error with the longest retry time
 | 
						|
            int minRetryIntervalForThisApp = err is RateLimitError
 | 
						|
                ? (err.remainingMinutes * 60)
 | 
						|
                : e is ClientException
 | 
						|
                ? (15 * 60)
 | 
						|
                : (toCheckApp.value + 1);
 | 
						|
            if (minRetryIntervalForThisApp > maxRetryWaitSeconds) {
 | 
						|
              minRetryIntervalForThisApp = maxRetryWaitSeconds;
 | 
						|
            }
 | 
						|
            if (minRetryIntervalForThisApp > retryAfterXSeconds) {
 | 
						|
              retryAfterXSeconds = minRetryIntervalForThisApp;
 | 
						|
            }
 | 
						|
          } else {
 | 
						|
            if (err is! RateLimitError) {
 | 
						|
              toThrow.add(key, err, appName: errors?.appIdNames[key]);
 | 
						|
            }
 | 
						|
          }
 | 
						|
        });
 | 
						|
      } else {
 | 
						|
        // We don't expect to ever get here in any situation so no need to catch (but log it in case)
 | 
						|
        logs.add('Fatal error in BG update task: ${e.toString()}');
 | 
						|
        rethrow;
 | 
						|
      }
 | 
						|
    } finally {
 | 
						|
      notificationsProvider.cancel(notif.id);
 | 
						|
    }
 | 
						|
 | 
						|
    // Filter out updates that will be installed silently (the rest go into toNotify)
 | 
						|
    for (var i = 0; i < updates.length; i++) {
 | 
						|
      var canInstallSilently = await appsProvider.canInstallSilently(
 | 
						|
        updates[i],
 | 
						|
      );
 | 
						|
      if (networkRestricted || chargingRestricted || !canInstallSilently) {
 | 
						|
        if (updates[i].additionalSettings['skipUpdateNotifications'] != true) {
 | 
						|
          logs.add(
 | 
						|
            'BG update task notifying for ${updates[i].id} (networkRestricted $networkRestricted, chargingRestricted: $chargingRestricted, canInstallSilently: $canInstallSilently).',
 | 
						|
          );
 | 
						|
          toNotify.add(updates[i]);
 | 
						|
        }
 | 
						|
      }
 | 
						|
    }
 | 
						|
 | 
						|
    // Send the update notification
 | 
						|
    if (toNotify.isNotEmpty) {
 | 
						|
      notificationsProvider.notify(UpdateNotification(toNotify));
 | 
						|
    }
 | 
						|
 | 
						|
    // Send the error notifications (grouped by error string)
 | 
						|
    if (toThrow.rawErrors.isNotEmpty) {
 | 
						|
      for (var element in toThrow.idsByErrorString.entries) {
 | 
						|
        notificationsProvider.notify(
 | 
						|
          ErrorCheckingUpdatesNotification(
 | 
						|
            errors!.errorsAppsString(element.key, element.value),
 | 
						|
            id: Random().nextInt(10000),
 | 
						|
          ),
 | 
						|
        );
 | 
						|
      }
 | 
						|
    }
 | 
						|
    // if there are update checks to retry, schedule a retry task
 | 
						|
    logs.add('BG update task: Done checking for updates.');
 | 
						|
    if (toRetry.isNotEmpty) {
 | 
						|
      logs.add(
 | 
						|
        'BG update task $taskId: Will retry in $retryAfterXSeconds seconds (${toRetry.length} to retry, ${toInstall.length} to install).',
 | 
						|
      );
 | 
						|
      return await bgUpdateCheck(taskId, {
 | 
						|
        'toCheck': toRetry
 | 
						|
            .map((entry) => {'key': entry.key, 'value': entry.value})
 | 
						|
            .toList(),
 | 
						|
        'toInstall': toInstall
 | 
						|
            .map((entry) => {'key': entry.key, 'value': entry.value})
 | 
						|
            .toList(),
 | 
						|
      });
 | 
						|
    } else {
 | 
						|
      // If there are no more update checks, call the function in install mode
 | 
						|
      logs.add(
 | 
						|
        'BG update task: Done checking for updates (${toRetry.length} to retry, ${toInstall.length} to install).',
 | 
						|
      );
 | 
						|
      return await bgUpdateCheck(taskId, {
 | 
						|
        'toCheck': [],
 | 
						|
        'toInstall': toInstall
 | 
						|
            .map((entry) => {'key': entry.key, 'value': entry.value})
 | 
						|
            .toList(),
 | 
						|
      });
 | 
						|
    }
 | 
						|
  } else {
 | 
						|
    // In install mode...
 | 
						|
    // If you haven't explicitly been given updates to install, grab all available silent updates
 | 
						|
    logs.add('BG install task: Started (${toInstall.length}).');
 | 
						|
    if (toInstall.isEmpty && !networkRestricted && !chargingRestricted) {
 | 
						|
      var temp = appsProvider.findExistingUpdates(installedOnly: true);
 | 
						|
      for (var i = 0; i < temp.length; i++) {
 | 
						|
        if (await appsProvider.canInstallSilently(
 | 
						|
          appsProvider.apps[temp[i]]!.app,
 | 
						|
        )) {
 | 
						|
          toInstall.add(MapEntry(temp[i], 0));
 | 
						|
        }
 | 
						|
      }
 | 
						|
    }
 | 
						|
    if (toInstall.isNotEmpty) {
 | 
						|
      var tempObtArr = toInstall.where(
 | 
						|
        (element) =>
 | 
						|
            element.key == obtainiumId || element.key == '$obtainiumId.fdroid',
 | 
						|
      );
 | 
						|
      if (tempObtArr.isNotEmpty) {
 | 
						|
        // Move obtainium to the end of the list as it must always install last
 | 
						|
        var obt = tempObtArr.first;
 | 
						|
        toInstall = moveStrToEndMapEntryWithCount(toInstall, obt);
 | 
						|
      }
 | 
						|
      // Loop through all updates and install each
 | 
						|
      try {
 | 
						|
        await appsProvider.downloadAndInstallLatestApps(
 | 
						|
          toInstall.map((e) => e.key).toList(),
 | 
						|
          null,
 | 
						|
          notificationsProvider: notificationsProvider,
 | 
						|
          forceParallelDownloads: true,
 | 
						|
        );
 | 
						|
      } catch (e) {
 | 
						|
        if (e is MultiAppMultiError) {
 | 
						|
          e.idsByErrorString.forEach((key, value) {
 | 
						|
            notificationsProvider.notify(
 | 
						|
              ErrorCheckingUpdatesNotification(e.errorsAppsString(key, value)),
 | 
						|
            );
 | 
						|
          });
 | 
						|
        } else {
 | 
						|
          // We don't expect to ever get here in any situation so no need to catch (but log it in case)
 | 
						|
          logs.add('Fatal error in BG install task: ${e.toString()}');
 | 
						|
          rethrow;
 | 
						|
        }
 | 
						|
      }
 | 
						|
      logs.add('BG install task: Done installing updates.');
 | 
						|
    }
 | 
						|
  }
 | 
						|
  appsProvider.settingsProvider.lastCompletedBGCheckTime = DateTime.now();
 | 
						|
}
 |