Compare commits
21 Commits
v0.1.4-bet
...
v0.1.9-bet
Author | SHA1 | Date | |
---|---|---|---|
294327bde4 | |||
52b97662c6 | |||
f63da4b538 | |||
c30c692d87 | |||
d643d5a474 | |||
f8101a5d9f | |||
c2a7e4a0d2 | |||
285da7545b | |||
a5230acc11 | |||
53019818a6 | |||
1a04d39144 | |||
96c1ed612d | |||
4d75a6a361 | |||
30075add1c | |||
52b4e1fb96 | |||
f9044e20f1 | |||
7e5affe1b8 | |||
5bdab1b1e4 | |||
c14c4d2f14 | |||
5e785ae1d5 | |||
6c076751ab |
18
README.md
@ -1,21 +1,25 @@
|
||||
#  Obtainium
|
||||
#  Obtainium
|
||||
|
||||
Get Android App Updates Directly From the Source.
|
||||
|
||||
Obtainium allows you to install and update Open-Source Apps directly from their releases pages, and receive notifications when new releases are made available.
|
||||
|
||||
Currently supported App sources:
|
||||
- GitHub
|
||||
- GitLab
|
||||
|
||||
Motivation: [Side Of Burritos - You should use this instead of F-Droid | How to use app RSS feed](https://youtu.be/FFz57zNR_M0)
|
||||
|
||||
Currently supported App sources:
|
||||
- [GitHub](https://github.com/)
|
||||
- [GitLab](https://gitlab.com/)
|
||||
- [F-Droid](https://f-droid.org/)
|
||||
- [Mullvad](https://mullvad.net/en/)
|
||||
- [Signal](https://signal.org/)
|
||||
|
||||
## Limitations
|
||||
- App installs are assumed to have succeeded; failures and cancelled installs cannot be detected.
|
||||
- Auto (unattended) updates are unsupported due to a lack of any capable Flutter plugin.
|
||||
- For some sources, data is gathered using Web scraping and can easily break due to changes in website design. In such cases, more reliable methods may be unavailable.
|
||||
|
||||
## Screenshots
|
||||
|
||||
| <img src="./screenshots/1.apps.png" alt="Apps Page" /> | <img src="./screenshots/2.dark_theme.png" alt="Dark Theme" /> | <img src="./screenshots/3.material_you.png" alt="Material You" /> |
|
||||
| <img src="./assets/screenshots/1.apps.png" alt="Apps Page" /> | <img src="./assets/screenshots/2.dark_theme.png" alt="Dark Theme" /> | <img src="./assets/screenshots/3.material_you.png" alt="Material You" /> |
|
||||
| ------------------------------------------------------ | ----------------------------------------------------------------------- | -------------------------------------------------------------------- |
|
||||
| <img src="./screenshots/4.app.png" alt="App Page" /> | <img src="./screenshots/5.apk_picker.png" alt="Multiple APK Support" /> | <img src="./screenshots/6.apk_install.png" alt="App Installation" /> |
|
||||
| <img src="./assets/screenshots/4.app.png" alt="App Page" /> | <img src="./assets/screenshots/5.apk_picker.png" alt="Multiple APK Support" /> | <img src="./assets/screenshots/6.apk_install.png" alt="App Installation" /> |
|
||||
|
Before Width: | Height: | Size: 4.1 KiB After Width: | Height: | Size: 4.0 KiB |
BIN
assets/graphics/banner.png
Executable file
After Width: | Height: | Size: 66 KiB |
Before Width: | Height: | Size: 21 KiB After Width: | Height: | Size: 21 KiB |
BIN
assets/graphics/icon.psd
Executable file
BIN
assets/graphics/obtainium.psd
Executable file
BIN
assets/graphics/store-icon.png
Executable file
After Width: | Height: | Size: 21 KiB |
Before Width: | Height: | Size: 99 KiB After Width: | Height: | Size: 99 KiB |
Before Width: | Height: | Size: 83 KiB After Width: | Height: | Size: 83 KiB |
Before Width: | Height: | Size: 86 KiB After Width: | Height: | Size: 86 KiB |
Before Width: | Height: | Size: 263 KiB After Width: | Height: | Size: 263 KiB |
Before Width: | Height: | Size: 200 KiB After Width: | Height: | Size: 200 KiB |
Before Width: | Height: | Size: 192 KiB After Width: | Height: | Size: 192 KiB |
@ -9,18 +9,21 @@ import 'package:permission_handler/permission_handler.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:workmanager/workmanager.dart';
|
||||
import 'package:dynamic_color/dynamic_color.dart';
|
||||
import 'package:device_info_plus/device_info_plus.dart';
|
||||
|
||||
const String currentReleaseTag =
|
||||
'v0.1.4-beta'; // KEEP THIS IN SYNC WITH GITHUB RELEASES
|
||||
'v0.1.9-beta'; // KEEP THIS IN SYNC WITH GITHUB RELEASES
|
||||
|
||||
@pragma('vm:entry-point')
|
||||
void bgTaskCallback() {
|
||||
// Background update checking process
|
||||
Workmanager().executeTask((task, taskName) async {
|
||||
var appsProvider = AppsProvider(bg: true);
|
||||
var notificationsProvider = NotificationsProvider();
|
||||
await notificationsProvider.notify(checkingUpdatesNotification);
|
||||
try {
|
||||
var appsProvider = AppsProvider();
|
||||
await notificationsProvider
|
||||
.cancel(ErrorCheckingUpdatesNotification('').id);
|
||||
await appsProvider.loadApps();
|
||||
List<App> updates = await appsProvider.checkUpdates();
|
||||
if (updates.isNotEmpty) {
|
||||
@ -41,16 +44,22 @@ void bgTaskCallback() {
|
||||
|
||||
void main() async {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
SystemChrome.setSystemUIOverlayStyle(
|
||||
const SystemUiOverlayStyle(systemNavigationBarColor: Colors.transparent),
|
||||
);
|
||||
SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
|
||||
if ((await DeviceInfoPlugin().androidInfo).version.sdkInt! >= 29) {
|
||||
SystemChrome.setSystemUIOverlayStyle(
|
||||
const SystemUiOverlayStyle(systemNavigationBarColor: Colors.transparent),
|
||||
);
|
||||
SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
|
||||
}
|
||||
Workmanager().initialize(
|
||||
bgTaskCallback,
|
||||
);
|
||||
runApp(MultiProvider(
|
||||
providers: [
|
||||
ChangeNotifierProvider(create: (context) => AppsProvider()),
|
||||
ChangeNotifierProvider(
|
||||
create: (context) => AppsProvider(
|
||||
shouldLoadApps: true,
|
||||
shouldCheckUpdatesAfterLoad: true,
|
||||
shouldDeleteAPKs: true)),
|
||||
ChangeNotifierProvider(create: (context) => SettingsProvider()),
|
||||
Provider(create: (context) => NotificationsProvider())
|
||||
],
|
||||
@ -69,12 +78,7 @@ class MyApp extends StatelessWidget {
|
||||
AppsProvider appsProvider = context.read<AppsProvider>();
|
||||
|
||||
if (settingsProvider.prefs == null) {
|
||||
settingsProvider.initializeSettings().then((value) {
|
||||
// Delete past downloads and check for updates every time the app is launched
|
||||
// Only runs once as the settings are only initialized once (so not on every build)
|
||||
appsProvider.deleteSavedAPKs();
|
||||
appsProvider.checkUpdates();
|
||||
});
|
||||
settingsProvider.initializeSettings();
|
||||
} else {
|
||||
// Register the background update task according to the user's setting
|
||||
Workmanager().registerPeriodicTask('bg-update-check', 'bg-update-check',
|
||||
@ -87,12 +91,14 @@ class MyApp extends StatelessWidget {
|
||||
// If this is the first run, ask for notification permissions and add Obtainium to the Apps list
|
||||
Permission.notification.request();
|
||||
appsProvider.saveApp(App(
|
||||
'imranr98_obtainium_github',
|
||||
'imranr98_obtainium_${GitHub().host}',
|
||||
'https://github.com/ImranR98/Obtainium',
|
||||
'ImranR98',
|
||||
'Obtainium',
|
||||
currentReleaseTag,
|
||||
currentReleaseTag, []));
|
||||
currentReleaseTag,
|
||||
[],
|
||||
0));
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -1,9 +1,11 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:obtainium/pages/app.dart';
|
||||
import 'package:obtainium/providers/apps_provider.dart';
|
||||
import 'package:obtainium/providers/settings_provider.dart';
|
||||
import 'package:obtainium/providers/source_provider.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:url_launcher/url_launcher_string.dart';
|
||||
|
||||
class AddAppPage extends StatefulWidget {
|
||||
const AddAppPage({super.key});
|
||||
@ -19,77 +21,114 @@ class _AddAppPageState extends State<AddAppPage> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
SourceProvider sourceProvider = SourceProvider();
|
||||
return Center(
|
||||
child: Form(
|
||||
key: _formKey,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
const Spacer(),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16.0),
|
||||
child: TextFormField(
|
||||
decoration: const InputDecoration(
|
||||
hintText: 'https://github.com/Author/Project',
|
||||
helperText: 'Enter the App source URL'),
|
||||
controller: urlInputController,
|
||||
validator: (value) {
|
||||
if (value == null ||
|
||||
value.isEmpty ||
|
||||
Uri.tryParse(value) == null) {
|
||||
return 'Please enter a supported source URL';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
)),
|
||||
Padding(
|
||||
padding:
|
||||
const EdgeInsets.symmetric(vertical: 16.0, horizontal: 16.0),
|
||||
child: ElevatedButton(
|
||||
onPressed: gettingAppInfo
|
||||
? null
|
||||
: () {
|
||||
if (_formKey.currentState!.validate()) {
|
||||
setState(() {
|
||||
gettingAppInfo = true;
|
||||
});
|
||||
sourceProvider()
|
||||
.getApp(urlInputController.value.text)
|
||||
.then((app) {
|
||||
var appsProvider = context.read<AppsProvider>();
|
||||
var settingsProvider =
|
||||
context.read<SettingsProvider>();
|
||||
if (appsProvider.apps.containsKey(app.id)) {
|
||||
throw 'App already added';
|
||||
}
|
||||
settingsProvider.getInstallPermission().then((_) {
|
||||
appsProvider.saveApp(app).then((_) {
|
||||
urlInputController.clear();
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) =>
|
||||
AppPage(appId: app.id)));
|
||||
});
|
||||
});
|
||||
}).catchError((e) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(e.toString())),
|
||||
);
|
||||
}).whenComplete(() {
|
||||
setState(() {
|
||||
gettingAppInfo = false;
|
||||
});
|
||||
});
|
||||
}
|
||||
},
|
||||
child: const Text('Add'),
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
if (gettingAppInfo) const LinearProgressIndicator(),
|
||||
],
|
||||
),
|
||||
));
|
||||
child: Form(
|
||||
key: _formKey,
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Container(),
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
TextFormField(
|
||||
decoration: const InputDecoration(
|
||||
hintText: 'https://github.com/Author/Project',
|
||||
helperText: 'Enter the App source URL'),
|
||||
controller: urlInputController,
|
||||
validator: (value) {
|
||||
if (value == null ||
|
||||
value.isEmpty ||
|
||||
Uri.tryParse(value) == null) {
|
||||
return 'Please enter a supported source URL';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 16.0),
|
||||
child: ElevatedButton(
|
||||
onPressed: gettingAppInfo
|
||||
? null
|
||||
: () {
|
||||
HapticFeedback.mediumImpact();
|
||||
if (_formKey.currentState!.validate()) {
|
||||
setState(() {
|
||||
gettingAppInfo = true;
|
||||
});
|
||||
sourceProvider
|
||||
.getApp(urlInputController.value.text)
|
||||
.then((app) {
|
||||
var appsProvider =
|
||||
context.read<AppsProvider>();
|
||||
var settingsProvider =
|
||||
context.read<SettingsProvider>();
|
||||
if (appsProvider.apps.containsKey(app.id)) {
|
||||
throw 'App already added';
|
||||
}
|
||||
settingsProvider
|
||||
.getInstallPermission()
|
||||
.then((_) {
|
||||
appsProvider.saveApp(app).then((_) {
|
||||
urlInputController.clear();
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) =>
|
||||
AppPage(appId: app.id)));
|
||||
});
|
||||
});
|
||||
}).catchError((e) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(e.toString())),
|
||||
);
|
||||
}).whenComplete(() {
|
||||
setState(() {
|
||||
gettingAppInfo = false;
|
||||
});
|
||||
});
|
||||
}
|
||||
},
|
||||
child: const Text('Add'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Column(crossAxisAlignment: CrossAxisAlignment.center, children: [
|
||||
const Text(
|
||||
'Supported Sources:',
|
||||
// style: TextStyle(fontWeight: FontWeight.bold),
|
||||
// style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
const SizedBox(
|
||||
height: 8,
|
||||
),
|
||||
...sourceProvider
|
||||
.getSourceHosts()
|
||||
.map((e) => GestureDetector(
|
||||
onTap: () {
|
||||
launchUrlString('https://$e',
|
||||
mode: LaunchMode.externalApplication);
|
||||
},
|
||||
child: Text(
|
||||
e,
|
||||
style: const TextStyle(
|
||||
decoration: TextDecoration.underline,
|
||||
fontStyle: FontStyle.italic),
|
||||
)))
|
||||
.toList()
|
||||
]),
|
||||
if (gettingAppInfo)
|
||||
const LinearProgressIndicator()
|
||||
else
|
||||
Container(),
|
||||
],
|
||||
)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -1,5 +1,8 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:obtainium/providers/apps_provider.dart';
|
||||
import 'package:obtainium/providers/settings_provider.dart';
|
||||
import 'package:url_launcher/url_launcher_string.dart';
|
||||
import 'package:webview_flutter/webview_flutter.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
@ -16,6 +19,7 @@ class _AppPageState extends State<AppPage> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
var appsProvider = context.watch<AppsProvider>();
|
||||
var settingsProvider = context.watch<SettingsProvider>();
|
||||
AppInMemory? app = appsProvider.apps[widget.appId];
|
||||
if (app?.app.installedVersion != null) {
|
||||
appsProvider.getUpdate(app!.app.id);
|
||||
@ -24,10 +28,58 @@ class _AppPageState extends State<AppPage> {
|
||||
appBar: AppBar(
|
||||
title: Text('${app?.app.author}/${app?.app.name}'),
|
||||
),
|
||||
body: WebView(
|
||||
initialUrl: app?.app.url,
|
||||
javascriptMode: JavascriptMode.unrestricted,
|
||||
),
|
||||
body: settingsProvider.showAppWebpage
|
||||
? WebView(
|
||||
initialUrl: app?.app.url,
|
||||
javascriptMode: JavascriptMode.unrestricted,
|
||||
)
|
||||
: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Text(
|
||||
app?.app.name ?? 'App',
|
||||
textAlign: TextAlign.center,
|
||||
style: Theme.of(context).textTheme.displayLarge,
|
||||
),
|
||||
Text(
|
||||
'By ${app?.app.author ?? 'Unknown'}',
|
||||
textAlign: TextAlign.center,
|
||||
style: Theme.of(context).textTheme.headlineMedium,
|
||||
),
|
||||
const SizedBox(
|
||||
height: 32,
|
||||
),
|
||||
GestureDetector(
|
||||
onTap: () {
|
||||
if (app?.app.url != null) {
|
||||
launchUrlString(app?.app.url ?? '',
|
||||
mode: LaunchMode.externalApplication);
|
||||
}
|
||||
},
|
||||
child: Text(
|
||||
app?.app.url ?? '',
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(
|
||||
decoration: TextDecoration.underline,
|
||||
fontStyle: FontStyle.italic,
|
||||
fontSize: 12),
|
||||
)),
|
||||
const SizedBox(
|
||||
height: 32,
|
||||
),
|
||||
Text(
|
||||
'Latest Version: ${app?.app.latestVersion ?? 'Unknown'}',
|
||||
textAlign: TextAlign.center,
|
||||
style: Theme.of(context).textTheme.bodyLarge,
|
||||
),
|
||||
Text(
|
||||
'Installed Version: ${app?.app.installedVersion ?? 'None'}',
|
||||
textAlign: TextAlign.center,
|
||||
style: Theme.of(context).textTheme.bodyLarge,
|
||||
),
|
||||
],
|
||||
),
|
||||
bottomSheet: Padding(
|
||||
padding: EdgeInsets.fromLTRB(
|
||||
0, 0, 0, MediaQuery.of(context).padding.bottom),
|
||||
@ -45,13 +97,16 @@ class _AppPageState extends State<AppPage> {
|
||||
appsProvider
|
||||
.checkAppObjectForUpdate(
|
||||
app!.app)) &&
|
||||
app?.downloadProgress == null
|
||||
!appsProvider.areDownloadsRunning()
|
||||
? () {
|
||||
HapticFeedback.heavyImpact();
|
||||
appsProvider
|
||||
.downloadAndInstallLatestApp(
|
||||
[app!.app.id],
|
||||
context).then((_) {
|
||||
Navigator.of(context).pop();
|
||||
context).then((res) {
|
||||
if (res && mounted) {
|
||||
Navigator.of(context).pop();
|
||||
}
|
||||
});
|
||||
}
|
||||
: null,
|
||||
@ -63,6 +118,7 @@ class _AppPageState extends State<AppPage> {
|
||||
onPressed: app?.downloadProgress != null
|
||||
? null
|
||||
: () {
|
||||
HapticFeedback.lightImpact();
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (BuildContext ctx) {
|
||||
@ -73,6 +129,7 @@ class _AppPageState extends State<AppPage> {
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
HapticFeedback.heavyImpact();
|
||||
appsProvider
|
||||
.removeApp(app!.app.id)
|
||||
.then((_) {
|
||||
@ -85,6 +142,7 @@ class _AppPageState extends State<AppPage> {
|
||||
child: const Text('Remove')),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
HapticFeedback.lightImpact();
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
child: const Text('Cancel'))
|
||||
@ -93,8 +151,10 @@ class _AppPageState extends State<AppPage> {
|
||||
});
|
||||
},
|
||||
style: TextButton.styleFrom(
|
||||
foregroundColor: Theme.of(context).errorColor,
|
||||
surfaceTintColor: Theme.of(context).errorColor),
|
||||
foregroundColor:
|
||||
Theme.of(context).colorScheme.error,
|
||||
surfaceTintColor:
|
||||
Theme.of(context).colorScheme.error),
|
||||
child: const Text('Remove'),
|
||||
),
|
||||
])),
|
||||
|
@ -1,4 +1,5 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:obtainium/pages/app.dart';
|
||||
import 'package:obtainium/providers/apps_provider.dart';
|
||||
import 'package:obtainium/providers/settings_provider.dart';
|
||||
@ -21,11 +22,10 @@ class _AppsPageState extends State<AppsPage> {
|
||||
floatingActionButton: existingUpdateAppIds.isEmpty
|
||||
? null
|
||||
: ElevatedButton.icon(
|
||||
onPressed: appsProvider.apps.values
|
||||
.where((element) => element.downloadProgress != null)
|
||||
.isNotEmpty
|
||||
onPressed: appsProvider.areDownloadsRunning()
|
||||
? null
|
||||
: () {
|
||||
HapticFeedback.heavyImpact();
|
||||
context
|
||||
.read<SettingsProvider>()
|
||||
.getInstallPermission()
|
||||
@ -42,10 +42,13 @@ class _AppsPageState extends State<AppsPage> {
|
||||
: appsProvider.apps.isEmpty
|
||||
? Text(
|
||||
'No Apps',
|
||||
style: Theme.of(context).textTheme.headline4,
|
||||
style: Theme.of(context).textTheme.headlineMedium,
|
||||
)
|
||||
: RefreshIndicator(
|
||||
onRefresh: appsProvider.checkUpdates,
|
||||
onRefresh: () {
|
||||
HapticFeedback.lightImpact();
|
||||
return appsProvider.checkUpdates();
|
||||
},
|
||||
child: ListView(
|
||||
children: appsProvider.apps.values
|
||||
.map(
|
||||
@ -55,7 +58,7 @@ class _AppsPageState extends State<AppsPage> {
|
||||
e.app.installedVersion ?? 'Not Installed'),
|
||||
trailing: e.downloadProgress != null
|
||||
? Text(
|
||||
'Downloading - ${e.downloadProgress!.toInt()}%')
|
||||
'Downloading - ${e.downloadProgress?.toInt()}%')
|
||||
: (e.app.installedVersion != null &&
|
||||
e.app.installedVersion !=
|
||||
e.app.latestVersion
|
||||
|
@ -1,4 +1,5 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:obtainium/pages/add_app.dart';
|
||||
import 'package:obtainium/pages/apps.dart';
|
||||
import 'package:obtainium/pages/settings.dart';
|
||||
@ -20,22 +21,34 @@ class _HomePageState extends State<HomePage> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('Obtainium')),
|
||||
body: pages.elementAt(selectedIndex),
|
||||
bottomNavigationBar: NavigationBar(
|
||||
destinations: const [
|
||||
NavigationDestination(icon: Icon(Icons.settings), label: 'Settings'),
|
||||
NavigationDestination(icon: Icon(Icons.apps), label: 'Apps'),
|
||||
NavigationDestination(icon: Icon(Icons.add), label: 'Add App'),
|
||||
],
|
||||
onDestinationSelected: (int index) {
|
||||
setState(() {
|
||||
selectedIndex = index;
|
||||
});
|
||||
},
|
||||
selectedIndex: selectedIndex,
|
||||
),
|
||||
);
|
||||
return WillPopScope(
|
||||
child: Scaffold(
|
||||
appBar: AppBar(title: const Text('Obtainium')),
|
||||
body: pages.elementAt(selectedIndex),
|
||||
bottomNavigationBar: NavigationBar(
|
||||
destinations: const [
|
||||
NavigationDestination(
|
||||
icon: Icon(Icons.settings), label: 'Settings'),
|
||||
NavigationDestination(icon: Icon(Icons.apps), label: 'Apps'),
|
||||
NavigationDestination(icon: Icon(Icons.add), label: 'Add App'),
|
||||
],
|
||||
onDestinationSelected: (int index) {
|
||||
HapticFeedback.lightImpact();
|
||||
setState(() {
|
||||
selectedIndex = index;
|
||||
});
|
||||
},
|
||||
selectedIndex: selectedIndex,
|
||||
),
|
||||
),
|
||||
onWillPop: () async {
|
||||
if (selectedIndex != 1) {
|
||||
setState(() {
|
||||
selectedIndex = 1;
|
||||
});
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
@ -1,6 +1,7 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:obtainium/providers/apps_provider.dart';
|
||||
import 'package:obtainium/providers/settings_provider.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
@ -109,7 +110,21 @@ class _SettingsPageState extends State<SettingsPage> {
|
||||
}
|
||||
}),
|
||||
const SizedBox(
|
||||
height: 32,
|
||||
height: 16,
|
||||
),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
const Text('Show Source Webpage in App View'),
|
||||
Switch(
|
||||
value: settingsProvider.showAppWebpage,
|
||||
onChanged: (value) {
|
||||
settingsProvider.showAppWebpage = value;
|
||||
})
|
||||
],
|
||||
),
|
||||
const SizedBox(
|
||||
height: 16,
|
||||
),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceAround,
|
||||
@ -118,6 +133,7 @@ class _SettingsPageState extends State<SettingsPage> {
|
||||
onPressed: appsProvider.apps.isEmpty
|
||||
? null
|
||||
: () {
|
||||
HapticFeedback.lightImpact();
|
||||
appsProvider.exportApps().then((String path) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
@ -125,9 +141,10 @@ class _SettingsPageState extends State<SettingsPage> {
|
||||
);
|
||||
});
|
||||
},
|
||||
child: const Text('Export Apps')),
|
||||
child: const Text('Export App List')),
|
||||
ElevatedButton(
|
||||
onPressed: () {
|
||||
HapticFeedback.lightImpact();
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (BuildContext ctx) {
|
||||
@ -137,7 +154,7 @@ class _SettingsPageState extends State<SettingsPage> {
|
||||
|
||||
return AlertDialog(
|
||||
scrollable: true,
|
||||
title: const Text('Import Apps'),
|
||||
title: const Text('Import App List'),
|
||||
content: Column(children: [
|
||||
const Text(
|
||||
'Copy the contents of the Obtainium export file and paste them into the field below:'),
|
||||
@ -172,6 +189,13 @@ class _SettingsPageState extends State<SettingsPage> {
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
HapticFeedback.lightImpact();
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
child: const Text('Cancel')),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
HapticFeedback.heavyImpact();
|
||||
if (formKey.currentState!
|
||||
.validate()) {
|
||||
appsProvider
|
||||
@ -183,7 +207,7 @@ class _SettingsPageState extends State<SettingsPage> {
|
||||
.showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
'$value Apps Imported')),
|
||||
'$value App${value == 1 ? '' : 's'} Imported')),
|
||||
);
|
||||
}).catchError((e) {
|
||||
ScaffoldMessenger.of(context)
|
||||
@ -198,16 +222,11 @@ class _SettingsPageState extends State<SettingsPage> {
|
||||
}
|
||||
},
|
||||
child: const Text('Import')),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
child: const Text('Cancel'))
|
||||
],
|
||||
);
|
||||
});
|
||||
},
|
||||
child: const Text('Import Apps'))
|
||||
child: const Text('Import App List'))
|
||||
],
|
||||
),
|
||||
const Spacer(),
|
||||
@ -223,13 +242,14 @@ class _SettingsPageState extends State<SettingsPage> {
|
||||
}),
|
||||
),
|
||||
onPressed: () {
|
||||
HapticFeedback.lightImpact();
|
||||
launchUrlString(settingsProvider.sourceUrl,
|
||||
mode: LaunchMode.externalApplication);
|
||||
},
|
||||
icon: const Icon(Icons.code),
|
||||
label: Text(
|
||||
'Source',
|
||||
style: Theme.of(context).textTheme.caption,
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
)
|
||||
],
|
||||
|
@ -6,6 +6,7 @@ import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:obtainium/providers/notifications_provider.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
@ -38,14 +39,26 @@ class AppsProvider with ChangeNotifier {
|
||||
late Stream<FGBGType> foregroundStream;
|
||||
late StreamSubscription<FGBGType> foregroundSubscription;
|
||||
|
||||
AppsProvider({bool bg = false}) {
|
||||
AppsProvider(
|
||||
{bool shouldLoadApps = false,
|
||||
bool shouldCheckUpdatesAfterLoad = false,
|
||||
bool shouldDeleteAPKs = false}) {
|
||||
// Subscribe to changes in the app foreground status
|
||||
foregroundStream = FGBGEvents.stream.asBroadcastStream();
|
||||
foregroundSubscription = foregroundStream.listen((event) async {
|
||||
isForeground = event == FGBGType.foreground;
|
||||
if (isForeground) await loadApps();
|
||||
});
|
||||
loadApps();
|
||||
if (shouldDeleteAPKs) {
|
||||
deleteSavedAPKs();
|
||||
}
|
||||
if (shouldLoadApps) {
|
||||
loadApps().then((_) {
|
||||
if (shouldCheckUpdatesAfterLoad) {
|
||||
checkUpdates();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Future<ApkFile> downloadApp(String apkUrl, String appId) async {
|
||||
@ -79,10 +92,14 @@ class AppsProvider with ChangeNotifier {
|
||||
return ApkFile(appId, downloadFile);
|
||||
}
|
||||
|
||||
bool areDownloadsRunning() => apps.values
|
||||
.where((element) => element.downloadProgress != null)
|
||||
.isNotEmpty;
|
||||
|
||||
// Given an AppId, uses stored info about the app to download an APK (with user input if needed) and install it
|
||||
// Installs can only be done in the foreground, so a notification is sent to get the user's attention if needed
|
||||
// Returns upon successful download, regardless of installation result
|
||||
Future<void> downloadAndInstallLatestApp(
|
||||
Future<bool> downloadAndInstallLatestApp(
|
||||
List<String> appIds, BuildContext context) async {
|
||||
NotificationsProvider notificationsProvider =
|
||||
context.read<NotificationsProvider>();
|
||||
@ -91,37 +108,36 @@ class AppsProvider with ChangeNotifier {
|
||||
if (apps[id] == null) {
|
||||
throw 'App not found';
|
||||
}
|
||||
String apkUrl = apps[id]!.app.apkUrls.last;
|
||||
// If the App has more than one APK, the user should pick one
|
||||
String? apkUrl = apps[id]!.app.apkUrls[apps[id]!.app.preferredApkIndex];
|
||||
if (apps[id]!.app.apkUrls.length > 1) {
|
||||
await showDialog(
|
||||
apkUrl = await showDialog(
|
||||
context: context,
|
||||
builder: (BuildContext ctx) {
|
||||
return AlertDialog(
|
||||
scrollable: true,
|
||||
title: const Text('Pick an APK'),
|
||||
content: Column(children: [
|
||||
Text(
|
||||
'${apps[id]!.app.name} has more than one package - pick one.'),
|
||||
...apps[id]!.app.apkUrls.map((u) => ListTile(
|
||||
title: Text(Uri.parse(u).pathSegments.last),
|
||||
leading: Radio<String>(
|
||||
value: u,
|
||||
groupValue: apkUrl,
|
||||
onChanged: (String? val) {
|
||||
apkUrl = val!;
|
||||
})))
|
||||
]),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
child: const Text('Continue'))
|
||||
],
|
||||
);
|
||||
return APKPicker(app: apps[id]!.app, initVal: apkUrl);
|
||||
});
|
||||
}
|
||||
appsToInstall.putIfAbsent(id, () => apkUrl);
|
||||
// If the picked APK comes from an origin different from the source, get user confirmation
|
||||
if (apkUrl != null &&
|
||||
!apkUrl.toLowerCase().startsWith(apps[id]!.app.url.toLowerCase())) {
|
||||
if (await showDialog(
|
||||
context: context,
|
||||
builder: (BuildContext ctx) {
|
||||
return APKOriginWarningDialog(
|
||||
sourceUrl: apps[id]!.app.url, apkUrl: apkUrl!);
|
||||
}) !=
|
||||
true) {
|
||||
apkUrl = null;
|
||||
}
|
||||
}
|
||||
if (apkUrl != null) {
|
||||
int urlInd = apps[id]!.app.apkUrls.indexOf(apkUrl);
|
||||
if (urlInd != apps[id]!.app.preferredApkIndex) {
|
||||
apps[id]!.app.preferredApkIndex = urlInd;
|
||||
await saveApp(apps[id]!.app);
|
||||
}
|
||||
appsToInstall.putIfAbsent(id, () => apkUrl!);
|
||||
}
|
||||
}
|
||||
|
||||
List<ApkFile> downloadedFiles = await Future.wait(appsToInstall.entries
|
||||
@ -131,6 +147,7 @@ class AppsProvider with ChangeNotifier {
|
||||
await notificationsProvider.notify(completeInstallationNotification,
|
||||
cancelExisting: true);
|
||||
await FGBGEvents.stream.first == FGBGType.foreground;
|
||||
await notificationsProvider.cancel(completeInstallationNotification.id);
|
||||
// We need to wait for the App to come to the foreground to install it
|
||||
// Can't try to call install plugin in a background isolate (may not have worked anyways) because of:
|
||||
// https://github.com/flutter/flutter/issues/13937
|
||||
@ -144,6 +161,8 @@ class AppsProvider with ChangeNotifier {
|
||||
apps[f.appId]!.app.installedVersion = apps[f.appId]!.app.latestVersion;
|
||||
await saveApp(apps[f.appId]!.app);
|
||||
}
|
||||
|
||||
return downloadedFiles.isNotEmpty;
|
||||
}
|
||||
|
||||
Future<Directory> getAppsDir() async {
|
||||
@ -209,9 +228,12 @@ class AppsProvider with ChangeNotifier {
|
||||
|
||||
Future<App?> getUpdate(String appId) async {
|
||||
App? currentApp = apps[appId]!.app;
|
||||
App newApp = await sourceProvider().getApp(currentApp.url);
|
||||
App newApp = await SourceProvider().getApp(currentApp.url);
|
||||
if (newApp.latestVersion != currentApp.latestVersion) {
|
||||
newApp.installedVersion = currentApp.installedVersion;
|
||||
if (currentApp.preferredApkIndex < newApp.apkUrls.length) {
|
||||
newApp.preferredApkIndex = currentApp.preferredApkIndex;
|
||||
}
|
||||
await saveApp(newApp);
|
||||
return newApp;
|
||||
}
|
||||
@ -281,3 +303,90 @@ class AppsProvider with ChangeNotifier {
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
class APKPicker extends StatefulWidget {
|
||||
const APKPicker({super.key, required this.app, this.initVal});
|
||||
|
||||
final App app;
|
||||
final String? initVal;
|
||||
|
||||
@override
|
||||
State<APKPicker> createState() => _APKPickerState();
|
||||
}
|
||||
|
||||
class _APKPickerState extends State<APKPicker> {
|
||||
String? apkUrl;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
apkUrl ??= widget.initVal;
|
||||
return AlertDialog(
|
||||
scrollable: true,
|
||||
title: const Text('Pick an APK'),
|
||||
content: Column(children: [
|
||||
Text('${widget.app.name} has more than one package:'),
|
||||
const SizedBox(height: 16),
|
||||
...widget.app.apkUrls.map((u) => RadioListTile<String>(
|
||||
title: Text(Uri.parse(u).pathSegments.last),
|
||||
value: u,
|
||||
groupValue: apkUrl,
|
||||
onChanged: (String? val) {
|
||||
setState(() {
|
||||
apkUrl = val;
|
||||
});
|
||||
}))
|
||||
]),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
HapticFeedback.lightImpact();
|
||||
Navigator.of(context).pop(null);
|
||||
},
|
||||
child: const Text('Cancel')),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
HapticFeedback.heavyImpact();
|
||||
Navigator.of(context).pop(apkUrl);
|
||||
},
|
||||
child: const Text('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: const Text('Warning'),
|
||||
content: Text(
|
||||
'The App source is \'${Uri.parse(widget.sourceUrl).host}\' but the release package comes from \'${Uri.parse(widget.apkUrl).host}\'. Continue?'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
HapticFeedback.lightImpact();
|
||||
Navigator.of(context).pop(null);
|
||||
},
|
||||
child: const Text('Cancel')),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
HapticFeedback.heavyImpact();
|
||||
Navigator.of(context).pop(true);
|
||||
},
|
||||
child: const Text('Continue'))
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -69,4 +69,13 @@ class SettingsProvider with ChangeNotifier {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
bool get showAppWebpage {
|
||||
return prefs?.getBool('showAppWebpage') ?? true;
|
||||
}
|
||||
|
||||
set showAppWebpage(bool show) {
|
||||
prefs?.setBool('showAppWebpage', show);
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
@ -29,8 +29,9 @@ class App {
|
||||
String? installedVersion;
|
||||
late String latestVersion;
|
||||
List<String> apkUrls = [];
|
||||
late int preferredApkIndex;
|
||||
App(this.id, this.url, this.author, this.name, this.installedVersion,
|
||||
this.latestVersion, this.apkUrls);
|
||||
this.latestVersion, this.apkUrls, this.preferredApkIndex);
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
@ -38,15 +39,19 @@ class App {
|
||||
}
|
||||
|
||||
factory App.fromJson(Map<String, dynamic> json) => App(
|
||||
json['id'] as String,
|
||||
json['url'] as String,
|
||||
json['author'] as String,
|
||||
json['name'] as String,
|
||||
json['installedVersion'] == null
|
||||
? null
|
||||
: json['installedVersion'] as String,
|
||||
json['latestVersion'] as String,
|
||||
List<String>.from(jsonDecode(json['apkUrls'])));
|
||||
json['id'] as String,
|
||||
json['url'] as String,
|
||||
json['author'] as String,
|
||||
json['name'] as String,
|
||||
json['installedVersion'] == null
|
||||
? null
|
||||
: json['installedVersion'] as String,
|
||||
json['latestVersion'] as String,
|
||||
List<String>.from(jsonDecode(json['apkUrls'])),
|
||||
json['preferredApkIndex'] == null
|
||||
? 0
|
||||
: json['preferredApkIndex'] as int,
|
||||
);
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'id': id,
|
||||
@ -56,6 +61,7 @@ class App {
|
||||
'installedVersion': installedVersion,
|
||||
'latestVersion': latestVersion,
|
||||
'apkUrls': jsonEncode(apkUrls),
|
||||
'preferredApkIndex': preferredApkIndex
|
||||
};
|
||||
}
|
||||
|
||||
@ -65,6 +71,12 @@ escapeRegEx(String s) {
|
||||
});
|
||||
}
|
||||
|
||||
const String couldNotFindReleases = 'Unable to fetch release info';
|
||||
const String couldNotFindLatestVersion =
|
||||
'Could not determine latest release version';
|
||||
const String notValidURL = 'Not a valid URL';
|
||||
const String noAPKFound = 'No APK found';
|
||||
|
||||
List<String> getLinksFromParsedHTML(
|
||||
Document dom, RegExp hrefPattern, String prependToLinks) =>
|
||||
dom
|
||||
@ -77,7 +89,7 @@ List<String> getLinksFromParsedHTML(
|
||||
.toList();
|
||||
|
||||
abstract class AppSource {
|
||||
late String sourceId;
|
||||
late String host;
|
||||
String standardizeURL(String url);
|
||||
Future<APKDetails> getLatestAPKDetails(String standardUrl);
|
||||
AppNames getAppNames(String standardUrl);
|
||||
@ -85,52 +97,60 @@ abstract class AppSource {
|
||||
|
||||
class GitHub implements AppSource {
|
||||
@override
|
||||
String sourceId = 'github';
|
||||
late String host = 'github.com';
|
||||
|
||||
@override
|
||||
String standardizeURL(String url) {
|
||||
RegExp standardUrlRegEx = RegExp(r'^https?://github.com/[^/]*/[^/]*');
|
||||
RegExp standardUrlRegEx = RegExp('^https?://$host/[^/]+/[^/]+');
|
||||
RegExpMatch? match = standardUrlRegEx.firstMatch(url.toLowerCase());
|
||||
if (match == null) {
|
||||
throw 'Not a valid URL';
|
||||
throw notValidURL;
|
||||
}
|
||||
return url.substring(0, match.end);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<APKDetails> getLatestAPKDetails(String standardUrl) async {
|
||||
// The GitHub RSS feed does not contain asset download details, so we use web scraping (avoid API due to rate limits)
|
||||
Response res = await get(Uri.parse('$standardUrl/releases/latest'));
|
||||
Response res = await get(Uri.parse(
|
||||
'https://api.$host/repos${standardUrl.substring('https://$host'.length)}/releases'));
|
||||
if (res.statusCode == 200) {
|
||||
var standardUri = Uri.parse(standardUrl);
|
||||
var parsedHtml = parse(res.body);
|
||||
var apkUrlList = getLinksFromParsedHTML(
|
||||
parsedHtml,
|
||||
RegExp(
|
||||
'^${escapeRegEx(standardUri.path)}/releases/download/[^/]+/[^/]+\\.apk\$',
|
||||
caseSensitive: false),
|
||||
standardUri.origin);
|
||||
if (apkUrlList.isEmpty) {
|
||||
throw 'No APK found';
|
||||
var releases = jsonDecode(res.body) as List<dynamic>;
|
||||
// Right now, the latest non-prerelease version is picked
|
||||
// If none exists, the latest prerelease version is picked
|
||||
// In the future, the user could be given a choice
|
||||
var nonPrereleaseReleases =
|
||||
releases.where((element) => element['prerelease'] != true).toList();
|
||||
var latestRelease = nonPrereleaseReleases.isNotEmpty
|
||||
? nonPrereleaseReleases[0]
|
||||
: releases.isNotEmpty
|
||||
? releases[0]
|
||||
: null;
|
||||
if (latestRelease == null) {
|
||||
throw couldNotFindReleases;
|
||||
}
|
||||
String getTag(String url) {
|
||||
List<String> parts = url.split('/');
|
||||
return parts[parts.length - 2];
|
||||
List<dynamic>? assets = latestRelease['assets'];
|
||||
List<String>? apkUrlList = assets
|
||||
?.map((e) {
|
||||
return e['browser_download_url'] != null
|
||||
? e['browser_download_url'] as String
|
||||
: '';
|
||||
})
|
||||
.where((element) => element.toLowerCase().endsWith('.apk'))
|
||||
.toList();
|
||||
if (apkUrlList == null || apkUrlList.isEmpty) {
|
||||
throw noAPKFound;
|
||||
}
|
||||
String? version = latestRelease['tag_name'];
|
||||
if (version == null) {
|
||||
throw couldNotFindLatestVersion;
|
||||
}
|
||||
return APKDetails(version, apkUrlList);
|
||||
} else {
|
||||
if (res.headers['x-ratelimit-remaining'] == '0') {
|
||||
throw 'Rate limit reached - try again in ${(int.parse(res.headers['x-ratelimit-reset'] ?? '1800000000') / 60000000).toString()} minutes';
|
||||
}
|
||||
|
||||
String latestTag = getTag(apkUrlList[0]);
|
||||
String? version = parsedHtml
|
||||
.querySelector('.octicon-tag')
|
||||
?.nextElementSibling
|
||||
?.innerHtml
|
||||
.trim();
|
||||
if (version == null) {
|
||||
throw 'Could not determine latest release version';
|
||||
}
|
||||
return APKDetails(version,
|
||||
apkUrlList.where((element) => getTag(element) == latestTag).toList());
|
||||
} else {
|
||||
throw 'Unable to fetch release info';
|
||||
throw couldNotFindReleases;
|
||||
}
|
||||
}
|
||||
|
||||
@ -144,47 +164,53 @@ class GitHub implements AppSource {
|
||||
|
||||
class GitLab implements AppSource {
|
||||
@override
|
||||
String sourceId = 'gitlab';
|
||||
late String host = 'gitlab.com';
|
||||
|
||||
@override
|
||||
String standardizeURL(String url) {
|
||||
RegExp standardUrlRegEx = RegExp(r'^https?://gitlab.com/[^/]*/[^/]*');
|
||||
RegExp standardUrlRegEx = RegExp('^https?://$host/[^/]+/[^/]+');
|
||||
RegExpMatch? match = standardUrlRegEx.firstMatch(url.toLowerCase());
|
||||
if (match == null) {
|
||||
throw 'Not a valid URL';
|
||||
throw notValidURL;
|
||||
}
|
||||
return url.substring(0, match.end);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<APKDetails> getLatestAPKDetails(String standardUrl) async {
|
||||
// GitLab provides an RSS feed with all the details we need
|
||||
Response res = await get(Uri.parse('$standardUrl/-/tags?format=atom'));
|
||||
if (res.statusCode == 200) {
|
||||
var standardUri = Uri.parse(standardUrl);
|
||||
var parsedHtml = parse(res.body);
|
||||
var entry = parsedHtml.querySelector('entry');
|
||||
var entryContent =
|
||||
parse(parseFragment(entry!.querySelector('content')!.innerHtml).text);
|
||||
var apkUrlList = getLinksFromParsedHTML(
|
||||
entryContent,
|
||||
RegExp(
|
||||
'^${escapeRegEx(standardUri.path)}/uploads/[^/]+/[^/]+\\.apk\$',
|
||||
caseSensitive: false),
|
||||
standardUri.origin);
|
||||
parse(parseFragment(entry?.querySelector('content')!.innerHtml).text);
|
||||
var apkUrlList = [
|
||||
...getLinksFromParsedHTML(
|
||||
entryContent,
|
||||
RegExp(
|
||||
'^${escapeRegEx(standardUri.path)}/uploads/[^/]+/[^/]+\\.apk\$',
|
||||
caseSensitive: false),
|
||||
standardUri.origin),
|
||||
// GitLab releases may contain links to externally hosted APKs
|
||||
...getLinksFromParsedHTML(entryContent,
|
||||
RegExp('/[^/]+\\.apk\$', caseSensitive: false), '')
|
||||
.where((element) => Uri.parse(element).host != '')
|
||||
.toList()
|
||||
];
|
||||
if (apkUrlList.isEmpty) {
|
||||
throw 'No APK found';
|
||||
throw noAPKFound;
|
||||
}
|
||||
|
||||
var entryId = entry.querySelector('id')?.innerHtml;
|
||||
var entryId = entry?.querySelector('id')?.innerHtml;
|
||||
var version =
|
||||
entryId == null ? null : Uri.parse(entryId).pathSegments.last;
|
||||
if (version == null) {
|
||||
throw 'Could not determine latest release version';
|
||||
throw couldNotFindLatestVersion;
|
||||
}
|
||||
return APKDetails(version, apkUrlList);
|
||||
} else {
|
||||
throw 'Unable to fetch release info';
|
||||
throw couldNotFindReleases;
|
||||
}
|
||||
}
|
||||
|
||||
@ -195,15 +221,142 @@ class GitLab implements AppSource {
|
||||
}
|
||||
}
|
||||
|
||||
class sourceProvider {
|
||||
class Signal implements AppSource {
|
||||
@override
|
||||
late String host = 'signal.org';
|
||||
|
||||
@override
|
||||
String standardizeURL(String url) {
|
||||
return 'https://$host';
|
||||
}
|
||||
|
||||
@override
|
||||
Future<APKDetails> getLatestAPKDetails(String standardUrl) async {
|
||||
Response res =
|
||||
await get(Uri.parse('https://updates.$host/android/latest.json'));
|
||||
if (res.statusCode == 200) {
|
||||
var json = jsonDecode(res.body);
|
||||
String? apkUrl = json['url'];
|
||||
if (apkUrl == null) {
|
||||
throw noAPKFound;
|
||||
}
|
||||
String? version = json['versionName'];
|
||||
if (version == null) {
|
||||
throw couldNotFindLatestVersion;
|
||||
}
|
||||
return APKDetails(version, [apkUrl]);
|
||||
} else {
|
||||
throw couldNotFindReleases;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
AppNames getAppNames(String standardUrl) => AppNames('Signal', 'Signal');
|
||||
}
|
||||
|
||||
class FDroid implements AppSource {
|
||||
@override
|
||||
late String host = 'f-droid.org';
|
||||
|
||||
@override
|
||||
String standardizeURL(String url) {
|
||||
RegExp standardUrlRegEx = RegExp('^https?://$host/[^/]+/packages/[^/]+');
|
||||
RegExpMatch? match = standardUrlRegEx.firstMatch(url.toLowerCase());
|
||||
if (match == null) {
|
||||
throw notValidURL;
|
||||
}
|
||||
return url.substring(0, match.end);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<APKDetails> getLatestAPKDetails(String standardUrl) async {
|
||||
Response res = await get(Uri.parse(standardUrl));
|
||||
if (res.statusCode == 200) {
|
||||
var latestReleaseDiv =
|
||||
parse(res.body).querySelector('#latest.package-version');
|
||||
var apkUrl = latestReleaseDiv
|
||||
?.querySelector('.package-version-download a')
|
||||
?.attributes['href'];
|
||||
if (apkUrl == null) {
|
||||
throw noAPKFound;
|
||||
}
|
||||
var version = latestReleaseDiv
|
||||
?.querySelector('.package-version-header b')
|
||||
?.innerHtml
|
||||
.split(' ')
|
||||
.last;
|
||||
if (version == null) {
|
||||
throw couldNotFindLatestVersion;
|
||||
}
|
||||
return APKDetails(version, [apkUrl]);
|
||||
} else {
|
||||
throw couldNotFindReleases;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
AppNames getAppNames(String standardUrl) {
|
||||
var name = Uri.parse(standardUrl).pathSegments.last;
|
||||
return AppNames(name, name);
|
||||
}
|
||||
}
|
||||
|
||||
class Mullvad implements AppSource {
|
||||
@override
|
||||
late String host = 'mullvad.net';
|
||||
|
||||
@override
|
||||
String standardizeURL(String url) {
|
||||
RegExp standardUrlRegEx = RegExp('^https?://$host');
|
||||
RegExpMatch? match = standardUrlRegEx.firstMatch(url.toLowerCase());
|
||||
if (match == null) {
|
||||
throw notValidURL;
|
||||
}
|
||||
return url.substring(0, match.end);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<APKDetails> getLatestAPKDetails(String standardUrl) async {
|
||||
Response res = await get(Uri.parse('$standardUrl/en/download/android'));
|
||||
if (res.statusCode == 200) {
|
||||
var version = parse(res.body)
|
||||
.querySelector('p.subtitle.is-6')
|
||||
?.querySelector('a')
|
||||
?.attributes['href']
|
||||
?.split('/')
|
||||
.last;
|
||||
if (version == null) {
|
||||
throw couldNotFindLatestVersion;
|
||||
}
|
||||
return APKDetails(
|
||||
version, ['https://mullvad.net/download/app/apk/latest']);
|
||||
} else {
|
||||
throw couldNotFindReleases;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
AppNames getAppNames(String standardUrl) {
|
||||
return AppNames('Mullvad-VPN', 'Mullvad-VPN');
|
||||
}
|
||||
}
|
||||
|
||||
class SourceProvider {
|
||||
List<AppSource> sources = [GitHub(), GitLab(), FDroid(), Mullvad(), Signal()];
|
||||
|
||||
// Add more source classes here so they are available via the service
|
||||
AppSource getSource(String url) {
|
||||
if (url.toLowerCase().contains('://github.com')) {
|
||||
return GitHub();
|
||||
} else if (url.toLowerCase().contains('://gitlab.com')) {
|
||||
return GitLab();
|
||||
AppSource? source;
|
||||
for (var s in sources) {
|
||||
if (url.toLowerCase().contains('://${s.host}')) {
|
||||
source = s;
|
||||
break;
|
||||
}
|
||||
}
|
||||
throw 'URL does not match a known source';
|
||||
if (source == null) {
|
||||
throw 'URL does not match a known source';
|
||||
}
|
||||
return source;
|
||||
}
|
||||
|
||||
Future<App> getApp(String url) async {
|
||||
@ -219,12 +372,15 @@ class sourceProvider {
|
||||
AppNames names = source.getAppNames(standardUrl);
|
||||
APKDetails apk = await source.getLatestAPKDetails(standardUrl);
|
||||
return App(
|
||||
'${names.author.toLowerCase()}_${names.name.toLowerCase()}_${source.sourceId}',
|
||||
'${names.author.toLowerCase()}_${names.name.toLowerCase()}_${source.host}',
|
||||
standardUrl,
|
||||
names.author[0].toUpperCase() + names.author.substring(1),
|
||||
names.name[0].toUpperCase() + names.name.substring(1),
|
||||
null,
|
||||
apk.version,
|
||||
apk.apkUrls);
|
||||
apk.apkUrls,
|
||||
apk.apkUrls.length - 1);
|
||||
}
|
||||
|
||||
List<String> getSourceHosts() => sources.map((e) => e.host).toList();
|
||||
}
|
||||
|
58
pubspec.lock
@ -92,13 +92,55 @@ packages:
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "0.7.8"
|
||||
device_info_plus:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: device_info_plus
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "4.1.2"
|
||||
device_info_plus_linux:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: device_info_plus_linux
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "3.0.0"
|
||||
device_info_plus_macos:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: device_info_plus_macos
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "3.0.0"
|
||||
device_info_plus_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: device_info_plus_platform_interface
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "3.0.0"
|
||||
device_info_plus_web:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: device_info_plus_web
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "3.0.0"
|
||||
device_info_plus_windows:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: device_info_plus_windows
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "4.0.0"
|
||||
dynamic_color:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: dynamic_color
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "1.5.3"
|
||||
version: "1.5.4"
|
||||
fake_async:
|
||||
dependency: transitive
|
||||
description:
|
||||
@ -152,7 +194,7 @@ packages:
|
||||
name: flutter_local_notifications
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "9.8.0+1"
|
||||
version: "9.9.1"
|
||||
flutter_local_notifications_linux:
|
||||
dependency: transitive
|
||||
description:
|
||||
@ -435,7 +477,7 @@ packages:
|
||||
name: shared_preferences_platform_interface
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "2.0.0"
|
||||
version: "2.1.0"
|
||||
shared_preferences_web:
|
||||
dependency: transitive
|
||||
description:
|
||||
@ -496,7 +538,7 @@ packages:
|
||||
name: test_api
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "0.4.12"
|
||||
version: "0.4.13"
|
||||
timezone:
|
||||
dependency: transitive
|
||||
description:
|
||||
@ -573,7 +615,7 @@ packages:
|
||||
name: vector_math
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "2.1.2"
|
||||
version: "2.1.3"
|
||||
webview_flutter:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@ -587,14 +629,14 @@ packages:
|
||||
name: webview_flutter_android
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "2.9.5"
|
||||
version: "2.10.0"
|
||||
webview_flutter_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: webview_flutter_platform_interface
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "1.9.2"
|
||||
version: "1.9.3"
|
||||
webview_flutter_wkwebview:
|
||||
dependency: transitive
|
||||
description:
|
||||
@ -639,4 +681,4 @@ packages:
|
||||
version: "3.1.1"
|
||||
sdks:
|
||||
dart: ">=2.19.0-79.0.dev <3.0.0"
|
||||
flutter: ">=3.1.0-0.0.pre.1036"
|
||||
flutter: ">=3.3.0"
|
||||
|
15
pubspec.yaml
@ -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
|
||||
# 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.
|
||||
version: 0.1.4+5 # When changing this, update the tag in main() accordingly
|
||||
version: 0.1.9+10 # When changing this, update the tag in main() accordingly
|
||||
|
||||
environment:
|
||||
sdk: '>=2.19.0-79.0.dev <3.0.0'
|
||||
@ -35,21 +35,22 @@ dependencies:
|
||||
|
||||
# The following adds the Cupertino Icons font to your application.
|
||||
# Use with the CupertinoIcons class for iOS style icons.
|
||||
cupertino_icons: ^1.0.2
|
||||
cupertino_icons: ^1.0.5
|
||||
path_provider: ^2.0.11
|
||||
flutter_fgbg: ^0.2.0 # Try removing reliance on this
|
||||
flutter_local_notifications: ^9.8.0+1
|
||||
flutter_local_notifications: ^9.9.1
|
||||
provider: ^6.0.3
|
||||
http: ^0.13.5
|
||||
webview_flutter: ^3.0.4
|
||||
workmanager: ^0.5.0
|
||||
dynamic_color: ^1.5.3
|
||||
dynamic_color: ^1.5.4
|
||||
install_plugin_v2: ^1.0.0 # Try replacing this
|
||||
html: ^0.15.0
|
||||
shared_preferences: ^2.0.15
|
||||
url_launcher: ^6.1.5
|
||||
permission_handler: ^10.0.0
|
||||
fluttertoast: ^8.0.9
|
||||
device_info_plus: ^4.1.2
|
||||
|
||||
|
||||
dev_dependencies:
|
||||
@ -62,13 +63,13 @@ dev_dependencies:
|
||||
# activated in the `analysis_options.yaml` file located at the root of your
|
||||
# package. See that file for information about deactivating specific lint
|
||||
# rules and activating additional ones.
|
||||
flutter_lints: ^2.0.0
|
||||
flutter_lints: ^2.0.1
|
||||
|
||||
flutter_icons:
|
||||
android: true
|
||||
image_path: "assets/icon.png"
|
||||
image_path: "assets/graphics/icon.png"
|
||||
adaptive_icon_background: "#FFFFFF"
|
||||
adaptive_icon_foreground: "assets/icon.png"
|
||||
adaptive_icon_foreground: "assets/graphics/icon.png"
|
||||
|
||||
# For information on the generic Dart part of this file, see the
|
||||
# following page: https://dart.dev/tools/pub/pubspec
|
||||
|