mirror of
https://github.com/ImranR98/Obtainium.git
synced 2025-07-18 23:39:41 +02:00
Compare commits
16 Commits
v0.14.11-b
...
v0.14.13-b
Author | SHA1 | Date | |
---|---|---|---|
|
17b5604f2a | ||
|
fdb6eed6d0 | ||
|
13de0437b8 | ||
|
a43c45f310 | ||
|
9c56a4d1fc | ||
|
2aea1d2631 | ||
|
118e05a0fa | ||
|
05f497787e | ||
|
53cf4d0234 | ||
|
6e735b1763 | ||
|
873a1a0683 | ||
|
27b1149d1c | ||
|
c1e64f111e | ||
|
b2af8448fd | ||
|
8f44338e76 | ||
|
e4a55abcb3 |
@@ -254,6 +254,8 @@
|
|||||||
"versionExtractionRegEx": "Version Extraction RegEx",
|
"versionExtractionRegEx": "Version Extraction RegEx",
|
||||||
"matchGroupToUse": "Match Group to Use",
|
"matchGroupToUse": "Match Group to Use",
|
||||||
"highlightTouchTargets": "Highlight less obvious touch targets",
|
"highlightTouchTargets": "Highlight less obvious touch targets",
|
||||||
|
"pickExportDir": "Pick Export Directory",
|
||||||
|
"autoExportOnChanges": "Auto-export on changes",
|
||||||
"removeAppQuestion": {
|
"removeAppQuestion": {
|
||||||
"one": "Remover App?",
|
"one": "Remover App?",
|
||||||
"other": "Remover Apps?"
|
"other": "Remover Apps?"
|
||||||
|
@@ -251,6 +251,8 @@
|
|||||||
"versionExtractionRegEx": "Version Extraction RegEx",
|
"versionExtractionRegEx": "Version Extraction RegEx",
|
||||||
"matchGroupToUse": "Match Group to Use",
|
"matchGroupToUse": "Match Group to Use",
|
||||||
"highlightTouchTargets": "Highlight less obvious touch targets",
|
"highlightTouchTargets": "Highlight less obvious touch targets",
|
||||||
|
"pickExportDir": "Pick Export Directory",
|
||||||
|
"autoExportOnChanges": "Auto-export on changes",
|
||||||
"removeAppQuestion": {
|
"removeAppQuestion": {
|
||||||
"one": "Želite li ukloniti aplikaciju?",
|
"one": "Želite li ukloniti aplikaciju?",
|
||||||
"other": "Želite li ukloniti aplikacije?"
|
"other": "Želite li ukloniti aplikacije?"
|
||||||
|
@@ -251,6 +251,8 @@
|
|||||||
"versionExtractionRegEx": "Version Extraction RegEx",
|
"versionExtractionRegEx": "Version Extraction RegEx",
|
||||||
"matchGroupToUse": "Match Group to Use",
|
"matchGroupToUse": "Match Group to Use",
|
||||||
"highlightTouchTargets": "Highlight less obvious touch targets",
|
"highlightTouchTargets": "Highlight less obvious touch targets",
|
||||||
|
"pickExportDir": "Pick Export Directory",
|
||||||
|
"autoExportOnChanges": "Auto-export on changes",
|
||||||
"removeAppQuestion": {
|
"removeAppQuestion": {
|
||||||
"one": "App entfernen?",
|
"one": "App entfernen?",
|
||||||
"other": "Apps entfernen?"
|
"other": "Apps entfernen?"
|
||||||
|
@@ -254,6 +254,8 @@
|
|||||||
"versionExtractionRegEx": "Version Extraction RegEx",
|
"versionExtractionRegEx": "Version Extraction RegEx",
|
||||||
"matchGroupToUse": "Match Group to Use",
|
"matchGroupToUse": "Match Group to Use",
|
||||||
"highlightTouchTargets": "Highlight less obvious touch targets",
|
"highlightTouchTargets": "Highlight less obvious touch targets",
|
||||||
|
"pickExportDir": "Pick Export Directory",
|
||||||
|
"autoExportOnChanges": "Auto-export on changes",
|
||||||
"removeAppQuestion": {
|
"removeAppQuestion": {
|
||||||
"one": "Remove App?",
|
"one": "Remove App?",
|
||||||
"other": "Remove Apps?"
|
"other": "Remove Apps?"
|
||||||
|
@@ -251,6 +251,8 @@
|
|||||||
"versionExtractionRegEx": "Version Extraction RegEx",
|
"versionExtractionRegEx": "Version Extraction RegEx",
|
||||||
"matchGroupToUse": "Match Group to Use",
|
"matchGroupToUse": "Match Group to Use",
|
||||||
"highlightTouchTargets": "Highlight less obvious touch targets",
|
"highlightTouchTargets": "Highlight less obvious touch targets",
|
||||||
|
"pickExportDir": "Pick Export Directory",
|
||||||
|
"autoExportOnChanges": "Auto-export on changes",
|
||||||
"removeAppQuestion": {
|
"removeAppQuestion": {
|
||||||
"one": "¿Eliminar Aplicación?",
|
"one": "¿Eliminar Aplicación?",
|
||||||
"other": "¿Eliminar Aplicaciones?"
|
"other": "¿Eliminar Aplicaciones?"
|
||||||
|
@@ -251,6 +251,8 @@
|
|||||||
"versionExtractionRegEx": "Version Extraction RegEx",
|
"versionExtractionRegEx": "Version Extraction RegEx",
|
||||||
"matchGroupToUse": "Match Group to Use",
|
"matchGroupToUse": "Match Group to Use",
|
||||||
"highlightTouchTargets": "Highlight less obvious touch targets",
|
"highlightTouchTargets": "Highlight less obvious touch targets",
|
||||||
|
"pickExportDir": "Pick Export Directory",
|
||||||
|
"autoExportOnChanges": "Auto-export on changes",
|
||||||
"removeAppQuestion": {
|
"removeAppQuestion": {
|
||||||
"one": "برنامه حذف شود؟",
|
"one": "برنامه حذف شود؟",
|
||||||
"other": "برنامه ها حذف شوند؟"
|
"other": "برنامه ها حذف شوند؟"
|
||||||
|
@@ -251,6 +251,8 @@
|
|||||||
"versionExtractionRegEx": "Version Extraction RegEx",
|
"versionExtractionRegEx": "Version Extraction RegEx",
|
||||||
"matchGroupToUse": "Match Group to Use",
|
"matchGroupToUse": "Match Group to Use",
|
||||||
"highlightTouchTargets": "Highlight less obvious touch targets",
|
"highlightTouchTargets": "Highlight less obvious touch targets",
|
||||||
|
"pickExportDir": "Pick Export Directory",
|
||||||
|
"autoExportOnChanges": "Auto-export on changes",
|
||||||
"removeAppQuestion": {
|
"removeAppQuestion": {
|
||||||
"one": "Supprimer l'application ?",
|
"one": "Supprimer l'application ?",
|
||||||
"other": "Supprimer les applications ?"
|
"other": "Supprimer les applications ?"
|
||||||
|
@@ -250,6 +250,8 @@
|
|||||||
"versionExtractionRegEx": "Version Extraction RegEx",
|
"versionExtractionRegEx": "Version Extraction RegEx",
|
||||||
"matchGroupToUse": "Match Group to Use",
|
"matchGroupToUse": "Match Group to Use",
|
||||||
"highlightTouchTargets": "Highlight less obvious touch targets",
|
"highlightTouchTargets": "Highlight less obvious touch targets",
|
||||||
|
"pickExportDir": "Pick Export Directory",
|
||||||
|
"autoExportOnChanges": "Auto-export on changes",
|
||||||
"removeAppQuestion": {
|
"removeAppQuestion": {
|
||||||
"one": "Eltávolítja az alkalmazást?",
|
"one": "Eltávolítja az alkalmazást?",
|
||||||
"other": "Eltávolítja az alkalmazást?"
|
"other": "Eltávolítja az alkalmazást?"
|
||||||
|
@@ -251,6 +251,8 @@
|
|||||||
"versionExtractionRegEx": "Version Extraction RegEx",
|
"versionExtractionRegEx": "Version Extraction RegEx",
|
||||||
"matchGroupToUse": "Match Group to Use",
|
"matchGroupToUse": "Match Group to Use",
|
||||||
"highlightTouchTargets": "Highlight less obvious touch targets",
|
"highlightTouchTargets": "Highlight less obvious touch targets",
|
||||||
|
"pickExportDir": "Pick Export Directory",
|
||||||
|
"autoExportOnChanges": "Auto-export on changes",
|
||||||
"removeAppQuestion": {
|
"removeAppQuestion": {
|
||||||
"one": "Rimuovere l'app?",
|
"one": "Rimuovere l'app?",
|
||||||
"other": "Rimuovere le app?"
|
"other": "Rimuovere le app?"
|
||||||
|
@@ -252,6 +252,8 @@
|
|||||||
"versionExtractionRegEx": "Version Extraction RegEx",
|
"versionExtractionRegEx": "Version Extraction RegEx",
|
||||||
"matchGroupToUse": "Match Group to Use",
|
"matchGroupToUse": "Match Group to Use",
|
||||||
"highlightTouchTargets": "Highlight less obvious touch targets",
|
"highlightTouchTargets": "Highlight less obvious touch targets",
|
||||||
|
"pickExportDir": "Pick Export Directory",
|
||||||
|
"autoExportOnChanges": "Auto-export on changes",
|
||||||
"removeAppQuestion": {
|
"removeAppQuestion": {
|
||||||
"one": "アプリを削除しますか?",
|
"one": "アプリを削除しますか?",
|
||||||
"other": "アプリを削除しますか?"
|
"other": "アプリを削除しますか?"
|
||||||
|
@@ -257,6 +257,8 @@
|
|||||||
"versionExtractionRegEx": "Version Extraction RegEx",
|
"versionExtractionRegEx": "Version Extraction RegEx",
|
||||||
"matchGroupToUse": "Match Group to Use",
|
"matchGroupToUse": "Match Group to Use",
|
||||||
"highlightTouchTargets": "Highlight less obvious touch targets",
|
"highlightTouchTargets": "Highlight less obvious touch targets",
|
||||||
|
"pickExportDir": "Pick Export Directory",
|
||||||
|
"autoExportOnChanges": "Auto-export on changes",
|
||||||
"removeAppQuestion": {
|
"removeAppQuestion": {
|
||||||
"one": "Usunąć aplikację?",
|
"one": "Usunąć aplikację?",
|
||||||
"few": "Usunąć aplikacje?",
|
"few": "Usunąć aplikacje?",
|
||||||
|
@@ -251,6 +251,8 @@
|
|||||||
"versionExtractionRegEx": "Version Extraction RegEx",
|
"versionExtractionRegEx": "Version Extraction RegEx",
|
||||||
"matchGroupToUse": "Match Group to Use",
|
"matchGroupToUse": "Match Group to Use",
|
||||||
"highlightTouchTargets": "Highlight less obvious touch targets",
|
"highlightTouchTargets": "Highlight less obvious touch targets",
|
||||||
|
"pickExportDir": "Pick Export Directory",
|
||||||
|
"autoExportOnChanges": "Auto-export on changes",
|
||||||
"removeAppQuestion": {
|
"removeAppQuestion": {
|
||||||
"one": "Удалить приложение?",
|
"one": "Удалить приложение?",
|
||||||
"other": "Удалить приложения?"
|
"other": "Удалить приложения?"
|
||||||
|
@@ -252,6 +252,8 @@
|
|||||||
"versionExtractionRegEx": "Version Extraction RegEx",
|
"versionExtractionRegEx": "Version Extraction RegEx",
|
||||||
"matchGroupToUse": "Match Group to Use",
|
"matchGroupToUse": "Match Group to Use",
|
||||||
"highlightTouchTargets": "Highlight less obvious touch targets",
|
"highlightTouchTargets": "Highlight less obvious touch targets",
|
||||||
|
"pickExportDir": "Pick Export Directory",
|
||||||
|
"autoExportOnChanges": "Auto-export on changes",
|
||||||
"removeAppQuestion": {
|
"removeAppQuestion": {
|
||||||
"one": "是否删除应用?",
|
"one": "是否删除应用?",
|
||||||
"other": "是否删除应用?"
|
"other": "是否删除应用?"
|
||||||
|
2
build.sh
2
build.sh
@@ -4,7 +4,9 @@
|
|||||||
CURR_DIR="$(pwd)"
|
CURR_DIR="$(pwd)"
|
||||||
trap "cd "$CURR_DIR"" EXIT
|
trap "cd "$CURR_DIR"" EXIT
|
||||||
|
|
||||||
|
if [ -z "$1" ]; then
|
||||||
git fetch && git merge origin/main && git push # Typically run after a PR to main, so bring dev up to date
|
git fetch && git merge origin/main && git push # Typically run after a PR to main, so bring dev up to date
|
||||||
|
fi
|
||||||
rm ./build/app/outputs/flutter-apk/* 2>/dev/null # Get rid of older builds if any
|
rm ./build/app/outputs/flutter-apk/* 2>/dev/null # Get rid of older builds if any
|
||||||
flutter build apk && flutter build apk --split-per-abi # Build (both split and combined APKs)
|
flutter build apk && flutter build apk --split-per-abi # Build (both split and combined APKs)
|
||||||
for file in ./build/app/outputs/flutter-apk/*.sha1; do gpg --sign --detach-sig "$file"; done # Generate PGP signatures
|
for file in ./build/app/outputs/flutter-apk/*.sha1; do gpg --sign --detach-sig "$file"; done # Generate PGP signatures
|
||||||
|
@@ -252,8 +252,10 @@ class GitHub extends AppSource {
|
|||||||
List<MapEntry<String, String>> getReleaseAPKUrls(dynamic release) =>
|
List<MapEntry<String, String>> getReleaseAPKUrls(dynamic release) =>
|
||||||
(release['assets'] as List<dynamic>?)
|
(release['assets'] as List<dynamic>?)
|
||||||
?.map((e) {
|
?.map((e) {
|
||||||
return e['name'] != null && e['url'] != null
|
return (e['name'] != null) &&
|
||||||
? MapEntry(e['name'] as String, e['url'] as String)
|
((e['url'] ?? e['browser_download_url']) != null)
|
||||||
|
? MapEntry(e['name'] as String,
|
||||||
|
(e['url'] ?? e['browser_download_url']) as String)
|
||||||
: const MapEntry('', '');
|
: const MapEntry('', '');
|
||||||
})
|
})
|
||||||
.where((element) => element.key.toLowerCase().endsWith('.apk'))
|
.where((element) => element.key.toLowerCase().endsWith('.apk'))
|
||||||
|
@@ -124,10 +124,7 @@ class HTML extends AppSource {
|
|||||||
additionalValidators: [
|
additionalValidators: [
|
||||||
(value) {
|
(value) {
|
||||||
value ??= '1';
|
value ??= '1';
|
||||||
if (int.tryParse(value) == null) {
|
return intValidator(value);
|
||||||
return tr('invalidInput');
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
])
|
])
|
||||||
]
|
]
|
||||||
|
@@ -19,7 +19,7 @@ import 'package:easy_localization/src/easy_localization_controller.dart';
|
|||||||
// ignore: implementation_imports
|
// ignore: implementation_imports
|
||||||
import 'package:easy_localization/src/localization.dart';
|
import 'package:easy_localization/src/localization.dart';
|
||||||
|
|
||||||
const String currentVersion = '0.14.11';
|
const String currentVersion = '0.14.13';
|
||||||
const String currentReleaseTag =
|
const String currentReleaseTag =
|
||||||
'v$currentVersion-beta'; // KEEP THIS IN SYNC WITH GITHUB RELEASES
|
'v$currentVersion-beta'; // KEEP THIS IN SYNC WITH GITHUB RELEASES
|
||||||
|
|
||||||
|
@@ -340,7 +340,7 @@ class _AppPageState extends State<AppPage> {
|
|||||||
var res = await appsProvider.downloadAndInstallLatestApps(
|
var res = await appsProvider.downloadAndInstallLatestApps(
|
||||||
app?.app.id != null ? [app!.app.id] : [],
|
app?.app.id != null ? [app!.app.id] : [],
|
||||||
globalNavigatorKey.currentContext,
|
globalNavigatorKey.currentContext,
|
||||||
settingsProvider);
|
);
|
||||||
if (app?.app.installedVersion != null && !trackOnly) {
|
if (app?.app.installedVersion != null && !trackOnly) {
|
||||||
// ignore: use_build_context_synchronously
|
// ignore: use_build_context_synchronously
|
||||||
showError(tr('appsUpdated'), context);
|
showError(tr('appsUpdated'), context);
|
||||||
|
@@ -381,8 +381,7 @@ class AppsPageState extends State<AppsPage> {
|
|||||||
: () {
|
: () {
|
||||||
appsProvider.downloadAndInstallLatestApps(
|
appsProvider.downloadAndInstallLatestApps(
|
||||||
[listedApps[appIndex].app.id],
|
[listedApps[appIndex].app.id],
|
||||||
globalNavigatorKey.currentContext,
|
globalNavigatorKey.currentContext).catchError((e) {
|
||||||
settingsProvider).catchError((e) {
|
|
||||||
showError(e, context);
|
showError(e, context);
|
||||||
return <String>[];
|
return <String>[];
|
||||||
});
|
});
|
||||||
@@ -452,12 +451,16 @@ class AppsPageState extends State<AppsPage> {
|
|||||||
child: Container(
|
child: Container(
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
color: Theme.of(context).primaryColor.withAlpha(
|
color: settingsProvider.highlightTouchTargets &&
|
||||||
(settingsProvider.highlightTouchTargets &&
|
showChangesFn != null
|
||||||
showChangesFn != null)
|
? (Theme.of(context).brightness == Brightness.light
|
||||||
? 20
|
? Theme.of(context).primaryColor
|
||||||
: 0)),
|
: Theme.of(context).primaryColorLight)
|
||||||
padding: const EdgeInsets.fromLTRB(12, 0, 12, 0),
|
.withAlpha(20)
|
||||||
|
: null),
|
||||||
|
padding: settingsProvider.highlightTouchTargets
|
||||||
|
? const EdgeInsetsDirectional.fromSTEB(12, 0, 12, 0)
|
||||||
|
: const EdgeInsetsDirectional.fromSTEB(24, 0, 0, 0),
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
crossAxisAlignment: CrossAxisAlignment.end,
|
crossAxisAlignment: CrossAxisAlignment.end,
|
||||||
@@ -695,8 +698,8 @@ class AppsPageState extends State<AppsPage> {
|
|||||||
toInstall.addAll(trackOnlyUpdateIdsAllOrSelected);
|
toInstall.addAll(trackOnlyUpdateIdsAllOrSelected);
|
||||||
}
|
}
|
||||||
appsProvider
|
appsProvider
|
||||||
.downloadAndInstallLatestApps(toInstall,
|
.downloadAndInstallLatestApps(
|
||||||
globalNavigatorKey.currentContext, settingsProvider)
|
toInstall, globalNavigatorKey.currentContext)
|
||||||
.catchError((e) {
|
.catchError((e) {
|
||||||
showError(e, context);
|
showError(e, context);
|
||||||
return <String>[];
|
return <String>[];
|
||||||
|
@@ -28,8 +28,8 @@ class _ImportExportPageState extends State<ImportExportPage> {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
SourceProvider sourceProvider = SourceProvider();
|
SourceProvider sourceProvider = SourceProvider();
|
||||||
var appsProvider = context.read<AppsProvider>();
|
var appsProvider = context.watch<AppsProvider>();
|
||||||
var settingsProvider = context.read<SettingsProvider>();
|
var settingsProvider = context.watch<SettingsProvider>();
|
||||||
|
|
||||||
var outlineButtonStyle = ButtonStyle(
|
var outlineButtonStyle = ButtonStyle(
|
||||||
shape: MaterialStateProperty.all(
|
shape: MaterialStateProperty.all(
|
||||||
@@ -102,10 +102,16 @@ class _ImportExportPageState extends State<ImportExportPage> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
runObtainiumExport() {
|
runObtainiumExport() async {
|
||||||
HapticFeedback.selectionClick();
|
HapticFeedback.selectionClick();
|
||||||
appsProvider.exportApps().then((String path) {
|
appsProvider
|
||||||
showError(tr('exportedTo', args: [path]), context);
|
.exportApps(
|
||||||
|
pickOnly: (await settingsProvider.getExportDir()) == null,
|
||||||
|
sp: settingsProvider)
|
||||||
|
.then((String? result) {
|
||||||
|
if (result != null) {
|
||||||
|
showError(tr('exportedTo', args: [result]), context);
|
||||||
|
}
|
||||||
}).catchError((e) {
|
}).catchError((e) {
|
||||||
showError(e, context);
|
showError(e, context);
|
||||||
});
|
});
|
||||||
@@ -300,6 +306,11 @@ class _ImportExportPageState extends State<ImportExportPage> {
|
|||||||
const EdgeInsets.symmetric(vertical: 8, horizontal: 16),
|
const EdgeInsets.symmetric(vertical: 8, horizontal: 16),
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
FutureBuilder(
|
||||||
|
future: settingsProvider.getExportDir(),
|
||||||
|
builder: (context, snapshot) {
|
||||||
|
return Column(
|
||||||
children: [
|
children: [
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
@@ -310,7 +321,10 @@ class _ImportExportPageState extends State<ImportExportPage> {
|
|||||||
importInProgress
|
importInProgress
|
||||||
? null
|
? null
|
||||||
: runObtainiumExport,
|
: runObtainiumExport,
|
||||||
child: Text(tr('obtainiumExport')))),
|
child: Text(tr(snapshot.data != null
|
||||||
|
? 'obtainiumExport'
|
||||||
|
: 'pickExportDir')),
|
||||||
|
)),
|
||||||
const SizedBox(
|
const SizedBox(
|
||||||
width: 16,
|
width: 16,
|
||||||
),
|
),
|
||||||
@@ -323,6 +337,39 @@ class _ImportExportPageState extends State<ImportExportPage> {
|
|||||||
child: Text(tr('obtainiumImport'))))
|
child: Text(tr('obtainiumImport'))))
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
if (snapshot.data != null)
|
||||||
|
Column(
|
||||||
|
children: [
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
GeneratedForm(
|
||||||
|
items: [
|
||||||
|
[
|
||||||
|
GeneratedFormSwitch(
|
||||||
|
'autoExportOnChanges',
|
||||||
|
label: tr('autoExportOnChanges'),
|
||||||
|
defaultValue: settingsProvider
|
||||||
|
.autoExportOnChanges,
|
||||||
|
)
|
||||||
|
]
|
||||||
|
],
|
||||||
|
onValueChanges:
|
||||||
|
(value, valid, isBuilding) {
|
||||||
|
if (valid && !isBuilding) {
|
||||||
|
if (value['autoExportOnChanges'] !=
|
||||||
|
null) {
|
||||||
|
settingsProvider
|
||||||
|
.autoExportOnChanges = value[
|
||||||
|
'autoExportOnChanges'] ==
|
||||||
|
true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
if (importInProgress)
|
if (importInProgress)
|
||||||
const Column(
|
const Column(
|
||||||
children: [
|
children: [
|
||||||
@@ -399,7 +446,7 @@ class _ImportExportPageState extends State<ImportExportPage> {
|
|||||||
fontStyle: FontStyle.italic, fontSize: 12)),
|
fontStyle: FontStyle.italic, fontSize: 12)),
|
||||||
const SizedBox(
|
const SizedBox(
|
||||||
height: 8,
|
height: 8,
|
||||||
)
|
),
|
||||||
],
|
],
|
||||||
)))
|
)))
|
||||||
]));
|
]));
|
||||||
|
@@ -31,6 +31,7 @@ import 'package:obtainium/providers/source_provider.dart';
|
|||||||
import 'package:http/http.dart';
|
import 'package:http/http.dart';
|
||||||
import 'package:android_intent_plus/android_intent.dart';
|
import 'package:android_intent_plus/android_intent.dart';
|
||||||
import 'package:flutter_archive/flutter_archive.dart';
|
import 'package:flutter_archive/flutter_archive.dart';
|
||||||
|
import 'package:shared_storage/shared_storage.dart' as saf;
|
||||||
|
|
||||||
final pm = AndroidPackageManager();
|
final pm = AndroidPackageManager();
|
||||||
|
|
||||||
@@ -150,6 +151,7 @@ class AppsProvider with ChangeNotifier {
|
|||||||
late Stream<FGBGType>? foregroundStream;
|
late Stream<FGBGType>? foregroundStream;
|
||||||
late StreamSubscription<FGBGType>? foregroundSubscription;
|
late StreamSubscription<FGBGType>? foregroundSubscription;
|
||||||
late Directory APKDir;
|
late Directory APKDir;
|
||||||
|
late SettingsProvider settingsProvider = SettingsProvider();
|
||||||
|
|
||||||
Iterable<AppInMemory> getAppValues() => apps.values.map((a) => a.deepCopy());
|
Iterable<AppInMemory> getAppValues() => apps.values.map((a) => a.deepCopy());
|
||||||
|
|
||||||
@@ -161,6 +163,7 @@ class AppsProvider with ChangeNotifier {
|
|||||||
if (isForeground) await loadApps();
|
if (isForeground) await loadApps();
|
||||||
});
|
});
|
||||||
() async {
|
() async {
|
||||||
|
await settingsProvider.initializeSettings();
|
||||||
var cacheDirs = await getExternalCacheDirectories();
|
var cacheDirs = await getExternalCacheDirectories();
|
||||||
if (cacheDirs?.isNotEmpty ?? false) {
|
if (cacheDirs?.isNotEmpty ?? false) {
|
||||||
APKDir = cacheDirs!.first;
|
APKDir = cacheDirs!.first;
|
||||||
@@ -369,8 +372,7 @@ class AppsProvider with ChangeNotifier {
|
|||||||
.where((element) => element.downloadProgress != null)
|
.where((element) => element.downloadProgress != null)
|
||||||
.isNotEmpty;
|
.isNotEmpty;
|
||||||
|
|
||||||
Future<bool> canInstallSilently(
|
Future<bool> canInstallSilently(App app) async {
|
||||||
App app, SettingsProvider settingsProvider) async {
|
|
||||||
if (app.id == obtainiumId) {
|
if (app.id == obtainiumId) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -539,7 +541,6 @@ class AppsProvider with ChangeNotifier {
|
|||||||
getHost(apkUrl.value) != getHost(app.url) &&
|
getHost(apkUrl.value) != getHost(app.url) &&
|
||||||
context != null) {
|
context != null) {
|
||||||
// ignore: use_build_context_synchronously
|
// ignore: use_build_context_synchronously
|
||||||
var settingsProvider = context.read<SettingsProvider>();
|
|
||||||
if (!(settingsProvider.hideAPKOriginWarning) &&
|
if (!(settingsProvider.hideAPKOriginWarning) &&
|
||||||
// ignore: use_build_context_synchronously
|
// ignore: use_build_context_synchronously
|
||||||
await showDialog(
|
await showDialog(
|
||||||
@@ -560,8 +561,8 @@ class AppsProvider with ChangeNotifier {
|
|||||||
// If no BuildContext is provided, apps that require user interaction are ignored
|
// 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
|
// 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
|
// Returns an array of Ids for Apps that were successfully downloaded, regardless of installation result
|
||||||
Future<List<String>> downloadAndInstallLatestApps(List<String> appIds,
|
Future<List<String>> downloadAndInstallLatestApps(
|
||||||
BuildContext? context, SettingsProvider settingsProvider,
|
List<String> appIds, BuildContext? context,
|
||||||
{NotificationsProvider? notificationsProvider}) async {
|
{NotificationsProvider? notificationsProvider}) async {
|
||||||
notificationsProvider =
|
notificationsProvider =
|
||||||
notificationsProvider ?? context?.read<NotificationsProvider>();
|
notificationsProvider ?? context?.read<NotificationsProvider>();
|
||||||
@@ -590,8 +591,7 @@ class AppsProvider with ChangeNotifier {
|
|||||||
apps[id]!.app.preferredApkIndex = urlInd;
|
apps[id]!.app.preferredApkIndex = urlInd;
|
||||||
await saveApps([apps[id]!.app]);
|
await saveApps([apps[id]!.app]);
|
||||||
}
|
}
|
||||||
if (context != null ||
|
if (context != null || await canInstallSilently(apps[id]!.app)) {
|
||||||
await canInstallSilently(apps[id]!.app, settingsProvider)) {
|
|
||||||
appsToInstall.add(id);
|
appsToInstall.add(id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -628,8 +628,7 @@ class AppsProvider with ChangeNotifier {
|
|||||||
downloadedDir = downloadedArtifact as DownloadedXApkDir;
|
downloadedDir = downloadedArtifact as DownloadedXApkDir;
|
||||||
}
|
}
|
||||||
var appId = downloadedFile?.appId ?? downloadedDir!.appId;
|
var appId = downloadedFile?.appId ?? downloadedDir!.appId;
|
||||||
bool willBeSilent =
|
bool willBeSilent = await canInstallSilently(apps[appId]!.app);
|
||||||
await canInstallSilently(apps[appId]!.app, settingsProvider);
|
|
||||||
if (!(await settingsProvider.getInstallPermission(enforce: false))) {
|
if (!(await settingsProvider.getInstallPermission(enforce: false))) {
|
||||||
throw ObtainiumError(tr('cancelled'));
|
throw ObtainiumError(tr('cancelled'));
|
||||||
}
|
}
|
||||||
@@ -678,8 +677,8 @@ class AppsProvider with ChangeNotifier {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<Directory> getAppsDir() async {
|
Future<Directory> getAppsDir() async {
|
||||||
Directory appsDir = Directory(
|
Directory appsDir =
|
||||||
'${(await getExternalStorageDirectory())?.path as String}/app_data');
|
Directory('${(await getExternalStorageDirectory())!.path}/app_data');
|
||||||
if (!appsDir.existsSync()) {
|
if (!appsDir.existsSync()) {
|
||||||
appsDir.createSync();
|
appsDir.createSync();
|
||||||
}
|
}
|
||||||
@@ -879,8 +878,6 @@ class AppsProvider with ChangeNotifier {
|
|||||||
.toList();
|
.toList();
|
||||||
// After reconciliation, delete externally uninstalled Apps if needed
|
// After reconciliation, delete externally uninstalled Apps if needed
|
||||||
if (removedAppIds.isNotEmpty) {
|
if (removedAppIds.isNotEmpty) {
|
||||||
var settingsProvider = SettingsProvider();
|
|
||||||
await settingsProvider.initializeSettings();
|
|
||||||
if (settingsProvider.removeOnExternalUninstall) {
|
if (settingsProvider.removeOnExternalUninstall) {
|
||||||
await removeApps(removedAppIds);
|
await removeApps(removedAppIds);
|
||||||
}
|
}
|
||||||
@@ -919,6 +916,7 @@ class AppsProvider with ChangeNotifier {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
|
exportApps(isAuto: true);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> removeApps(List<String> appIds) async {
|
Future<void> removeApps(List<String> appIds) async {
|
||||||
@@ -940,6 +938,7 @@ class AppsProvider with ChangeNotifier {
|
|||||||
}
|
}
|
||||||
if (appIds.isNotEmpty) {
|
if (appIds.isNotEmpty) {
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
|
exportApps(isAuto: true);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1096,32 +1095,51 @@ class AppsProvider with ChangeNotifier {
|
|||||||
return updateAppIds;
|
return updateAppIds;
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<String> exportApps() async {
|
Future<String?> exportApps(
|
||||||
if ((await DeviceInfoPlugin().androidInfo).version.sdkInt <= 29) {
|
{bool pickOnly = false, isAuto = false, SettingsProvider? sp}) async {
|
||||||
if (await Permission.storage.isDenied) {
|
SettingsProvider settingsProvider = sp ?? this.settingsProvider;
|
||||||
await Permission.storage.request();
|
var exportDir = await settingsProvider.getExportDir();
|
||||||
|
if (isAuto) {
|
||||||
|
if (settingsProvider.autoExportOnChanges != true) {
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
if (await Permission.storage.isDenied) {
|
if (exportDir == null) {
|
||||||
throw ObtainiumError(tr('storagePermissionDenied'));
|
logs.add('Skipping auto-export as dir is not set.');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
logs.add('Started auto-export.');
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
logs.add('Previous auto-export deleted.');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Directory? exportDir = Directory('/storage/emulated/0/Download');
|
if (exportDir == null || pickOnly) {
|
||||||
String path = 'Downloads'; // TODO: See if hardcoding this can be avoided
|
await settingsProvider.pickExportDir();
|
||||||
var downloadsAccessible = false;
|
exportDir = await settingsProvider.getExportDir();
|
||||||
try {
|
|
||||||
downloadsAccessible = exportDir.existsSync();
|
|
||||||
} catch (e) {
|
|
||||||
logs.add('Error accessing Downloads (will use fallback): $e');
|
|
||||||
}
|
}
|
||||||
if (!downloadsAccessible) {
|
if (exportDir == null) {
|
||||||
exportDir = await getExternalStorageDirectory();
|
return null;
|
||||||
path = exportDir!.path;
|
|
||||||
}
|
}
|
||||||
File export = File(
|
String? returnPath;
|
||||||
'${exportDir.path}/${tr('obtainiumExportHyphenatedLowercase')}-${DateTime.now().millisecondsSinceEpoch}.json');
|
if (!pickOnly) {
|
||||||
export.writeAsStringSync(
|
var result = await saf.createFile(exportDir,
|
||||||
jsonEncode(apps.values.map((e) => e.app.toJson()).toList()));
|
displayName:
|
||||||
return path;
|
'${tr('obtainiumExportHyphenatedLowercase')}-${DateTime.now().toIso8601String().replaceAll(':', '-')}${isAuto ? '-auto' : ''}.json',
|
||||||
|
mimeType: 'application/json',
|
||||||
|
content: jsonEncode(apps.values.map((e) => e.app.toJson()).toList()));
|
||||||
|
if (result == null) {
|
||||||
|
throw ObtainiumError(tr('unexpectedError'));
|
||||||
|
}
|
||||||
|
returnPath =
|
||||||
|
exportDir.pathSegments.join('/').replaceFirst('tree/primary:', '/');
|
||||||
|
}
|
||||||
|
return returnPath;
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<int> importApps(String appsJSON) async {
|
Future<int> importApps(String appsJSON) async {
|
||||||
@@ -1298,14 +1316,12 @@ Future<void> bgUpdateCheck(int taskId, Map<String, dynamic>? params) async {
|
|||||||
NotificationsProvider notificationsProvider = NotificationsProvider();
|
NotificationsProvider notificationsProvider = NotificationsProvider();
|
||||||
AppsProvider appsProvider = AppsProvider(isBg: true);
|
AppsProvider appsProvider = AppsProvider(isBg: true);
|
||||||
await appsProvider.loadApps();
|
await appsProvider.loadApps();
|
||||||
var settingsProvider = SettingsProvider();
|
|
||||||
await settingsProvider.initializeSettings();
|
|
||||||
|
|
||||||
int maxAttempts = 4;
|
int maxAttempts = 4;
|
||||||
|
|
||||||
params ??= {};
|
params ??= {};
|
||||||
if (params['toCheck'] == null) {
|
if (params['toCheck'] == null) {
|
||||||
settingsProvider.lastBGCheckTime = DateTime.now();
|
appsProvider.settingsProvider.lastBGCheckTime = DateTime.now();
|
||||||
}
|
}
|
||||||
List<MapEntry<String, int>> toCheck = <MapEntry<String, int>>[
|
List<MapEntry<String, int>> toCheck = <MapEntry<String, int>>[
|
||||||
...(params['toCheck']
|
...(params['toCheck']
|
||||||
@@ -1335,7 +1351,7 @@ Future<void> bgUpdateCheck(int taskId, Map<String, dynamic>? params) async {
|
|||||||
var didCompleteChecking = false;
|
var didCompleteChecking = false;
|
||||||
CheckingUpdatesNotification? notif;
|
CheckingUpdatesNotification? notif;
|
||||||
var networkRestricted = false;
|
var networkRestricted = false;
|
||||||
if (settingsProvider.bgUpdatesOnWiFiOnly) {
|
if (appsProvider.settingsProvider.bgUpdatesOnWiFiOnly) {
|
||||||
var netResult = await (Connectivity().checkConnectivity());
|
var netResult = await (Connectivity().checkConnectivity());
|
||||||
networkRestricted = (netResult != ConnectivityResult.wifi) &&
|
networkRestricted = (netResult != ConnectivityResult.wifi) &&
|
||||||
(netResult != ConnectivityResult.ethernet);
|
(netResult != ConnectivityResult.ethernet);
|
||||||
@@ -1355,8 +1371,7 @@ Future<void> bgUpdateCheck(int taskId, Map<String, dynamic>? params) async {
|
|||||||
App? newApp = await appsProvider.checkUpdate(appId);
|
App? newApp = await appsProvider.checkUpdate(appId);
|
||||||
if (newApp != null) {
|
if (newApp != null) {
|
||||||
if (networkRestricted ||
|
if (networkRestricted ||
|
||||||
!(await appsProvider.canInstallSilently(
|
!(await appsProvider.canInstallSilently(app!.app))) {
|
||||||
app!.app, settingsProvider))) {
|
|
||||||
toNotify.add(newApp);
|
toNotify.add(newApp);
|
||||||
} else {
|
} else {
|
||||||
toInstall.add(MapEntry(appId, 0));
|
toInstall.add(MapEntry(appId, 0));
|
||||||
@@ -1442,8 +1457,7 @@ Future<void> bgUpdateCheck(int taskId, Map<String, dynamic>? params) async {
|
|||||||
try {
|
try {
|
||||||
logs.add(
|
logs.add(
|
||||||
'BG install task $taskId: Attempting to update $appId in the background.');
|
'BG install task $taskId: Attempting to update $appId in the background.');
|
||||||
await appsProvider.downloadAndInstallLatestApps(
|
await appsProvider.downloadAndInstallLatestApps([appId], null,
|
||||||
[appId], null, settingsProvider,
|
|
||||||
notificationsProvider: notificationsProvider);
|
notificationsProvider: notificationsProvider);
|
||||||
await Future.delayed(const Duration(
|
await Future.delayed(const Duration(
|
||||||
seconds:
|
seconds:
|
||||||
|
@@ -9,8 +9,10 @@ import 'package:obtainium/app_sources/github.dart';
|
|||||||
import 'package:obtainium/main.dart';
|
import 'package:obtainium/main.dart';
|
||||||
import 'package:obtainium/providers/apps_provider.dart';
|
import 'package:obtainium/providers/apps_provider.dart';
|
||||||
import 'package:obtainium/providers/source_provider.dart';
|
import 'package:obtainium/providers/source_provider.dart';
|
||||||
|
import 'package:path_provider/path_provider.dart';
|
||||||
import 'package:permission_handler/permission_handler.dart';
|
import 'package:permission_handler/permission_handler.dart';
|
||||||
import 'package:shared_preferences/shared_preferences.dart';
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
import 'package:shared_storage/shared_storage.dart' as saf;
|
||||||
|
|
||||||
String obtainiumTempId = 'imranr98_obtainium_${GitHub().host}';
|
String obtainiumTempId = 'imranr98_obtainium_${GitHub().host}';
|
||||||
String obtainiumId = 'dev.imranr.obtainium';
|
String obtainiumId = 'dev.imranr.obtainium';
|
||||||
@@ -35,6 +37,7 @@ List<int> updateIntervals = [15, 30, 60, 120, 180, 360, 720, 1440, 4320, 0]
|
|||||||
|
|
||||||
class SettingsProvider with ChangeNotifier {
|
class SettingsProvider with ChangeNotifier {
|
||||||
SharedPreferences? prefs;
|
SharedPreferences? prefs;
|
||||||
|
String? defaultAppDir;
|
||||||
bool justStarted = true;
|
bool justStarted = true;
|
||||||
|
|
||||||
String sourceUrl = 'https://github.com/ImranR98/Obtainium';
|
String sourceUrl = 'https://github.com/ImranR98/Obtainium';
|
||||||
@@ -42,6 +45,7 @@ class SettingsProvider with ChangeNotifier {
|
|||||||
// Not done in constructor as we want to be able to await it
|
// Not done in constructor as we want to be able to await it
|
||||||
Future<void> initializeSettings() async {
|
Future<void> initializeSettings() async {
|
||||||
prefs = await SharedPreferences.getInstance();
|
prefs = await SharedPreferences.getInstance();
|
||||||
|
defaultAppDir = (await getExternalStorageDirectory())!.path;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -357,4 +361,49 @@ class SettingsProvider with ChangeNotifier {
|
|||||||
prefs?.setBool('highlightTouchTargets', val);
|
prefs?.setBool('highlightTouchTargets', val);
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<Uri?> getExportDir() async {
|
||||||
|
var uriString = prefs?.getString('exportDir');
|
||||||
|
if (uriString != null) {
|
||||||
|
Uri? uri = Uri.parse(uriString);
|
||||||
|
if (!(await saf.canRead(uri) ?? false) ||
|
||||||
|
!(await saf.canWrite(uri) ?? false)) {
|
||||||
|
uri = null;
|
||||||
|
prefs?.remove('exportDir');
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
return uri;
|
||||||
|
} else {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> pickExportDir({bool remove = false}) async {
|
||||||
|
var existingSAFPerms = (await saf.persistedUriPermissions()) ?? [];
|
||||||
|
var currentOneWayDataSyncDir = await getExportDir();
|
||||||
|
Uri? newOneWayDataSyncDir;
|
||||||
|
if (!remove) {
|
||||||
|
newOneWayDataSyncDir = (await saf.openDocumentTree());
|
||||||
|
}
|
||||||
|
if (currentOneWayDataSyncDir?.path != newOneWayDataSyncDir?.path) {
|
||||||
|
if (newOneWayDataSyncDir == null) {
|
||||||
|
prefs?.remove('exportDir');
|
||||||
|
} else {
|
||||||
|
prefs?.setString('exportDir', newOneWayDataSyncDir.toString());
|
||||||
|
}
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
for (var e in existingSAFPerms) {
|
||||||
|
await saf.releasePersistableUriPermission(e.uri);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bool get autoExportOnChanges {
|
||||||
|
return prefs?.getBool('autoExportOnChanges') ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
|
set autoExportOnChanges(bool val) {
|
||||||
|
prefs?.setBool('autoExportOnChanges', val);
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@@ -521,6 +521,20 @@ regExValidator(String? value) {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
intValidator(String? value, {bool positive = false}) {
|
||||||
|
if (value == null) {
|
||||||
|
return tr('invalidInput');
|
||||||
|
}
|
||||||
|
var num = int.tryParse(value);
|
||||||
|
if (num == null) {
|
||||||
|
return tr('invalidInput');
|
||||||
|
}
|
||||||
|
if (positive && num <= 0) {
|
||||||
|
return tr('invalidInput');
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
class SourceProvider {
|
class SourceProvider {
|
||||||
// Add more source classes here so they are available via the service
|
// Add more source classes here so they are available via the service
|
||||||
List<AppSource> get sources => [
|
List<AppSource> get sources => [
|
||||||
|
@@ -686,6 +686,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.3.1"
|
version: "2.3.1"
|
||||||
|
shared_storage:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: shared_storage
|
||||||
|
sha256: "7c65a9d64f0f5521256be974cfd74010af12196657cec9f9fb7b03b2f11bcaf6"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.8.0"
|
||||||
sky_engine:
|
sky_engine:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description: flutter
|
description: flutter
|
||||||
|
@@ -17,7 +17,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev
|
|||||||
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
|
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
|
||||||
# In Windows, build-name is used as the major, minor, and patch parts
|
# In Windows, build-name is used as the major, minor, and patch parts
|
||||||
# of the product and file versions while build-number is used as the build suffix.
|
# of the product and file versions while build-number is used as the build suffix.
|
||||||
version: 0.14.11+203 # When changing this, update the tag in main() accordingly
|
version: 0.14.13+205 # When changing this, update the tag in main() accordingly
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: '>=3.0.0 <4.0.0'
|
sdk: '>=3.0.0 <4.0.0'
|
||||||
@@ -65,6 +65,7 @@ dependencies:
|
|||||||
flutter_archive: ^5.0.0
|
flutter_archive: ^5.0.0
|
||||||
hsluv: ^1.1.3
|
hsluv: ^1.1.3
|
||||||
connectivity_plus: ^4.0.2
|
connectivity_plus: ^4.0.2
|
||||||
|
shared_storage: ^0.8.0
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
flutter_test:
|
flutter_test:
|
||||||
|
Reference in New Issue
Block a user