Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Ensure headers are still sent when URL request is redirected (#1973), Add 'ETag header' option for HTML and direct APK links (#2221), Ensure links on add app page do not overlap/merge (#2216) #2225

Merged
merged 8 commits into from
Apr 6, 2025
27 changes: 19 additions & 8 deletions lib/app_sources/directAPKLink.dart
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:obtainium/app_sources/html.dart';
import 'package:obtainium/components/generated_form.dart';
import 'package:obtainium/custom_errors.dart';
import 'package:obtainium/providers/source_provider.dart';

Expand All @@ -8,12 +9,23 @@ class DirectAPKLink extends AppSource {

DirectAPKLink() {
name = tr('directAPKLink');
additionalSourceAppSpecificSettingFormItems = html
.additionalSourceAppSpecificSettingFormItems
.where((element) => element
.where((element) => element.key == 'requestHeader')
.isNotEmpty)
.toList();
additionalSourceAppSpecificSettingFormItems = [
...html.additionalSourceAppSpecificSettingFormItems
.where((element) => element
.where((element) => element.key == 'requestHeader')
.isNotEmpty)
.toList(),
[
GeneratedFormDropdown(
'defaultPseudoVersioningMethod',
[
MapEntry('partialAPKHash', tr('partialAPKHash')),
MapEntry('ETag', 'ETag')
],
label: tr('defaultPseudoVersioningMethod'),
defaultValue: 'partialAPKHash')
]
];
excludeCommonSettingKeys = [
'versionExtractionRegEx',
'matchGroupToUse',
Expand Down Expand Up @@ -57,9 +69,8 @@ class DirectAPKLink extends AppSource {
additionalSettingsNew[s] = additionalSettings[s];
}
}
additionalSettingsNew['defaultPseudoVersioningMethod'] = 'partialAPKHash';
additionalSettingsNew['directAPKLink'] = true;
additionalSettings['versionDetection'] = false;
additionalSettingsNew['versionDetection'] = false;
return html.getLatestAPKDetails(standardUrl, additionalSettingsNew);
}
}
29 changes: 20 additions & 9 deletions lib/app_sources/html.dart
Original file line number Diff line number Diff line change
Expand Up @@ -263,7 +263,8 @@ class HTML extends AppSource {
'defaultPseudoVersioningMethod',
[
MapEntry('partialAPKHash', tr('partialAPKHash')),
MapEntry('APKLinkHash', tr('APKLinkHash'))
MapEntry('APKLinkHash', tr('APKLinkHash')),
MapEntry('ETag', 'ETag')
],
label: tr('defaultPseudoVersioningMethod'),
defaultValue: 'partialAPKHash')
Expand Down Expand Up @@ -356,14 +357,24 @@ class HTML extends AppSource {
additionalSettings['versionExtractWholePage'] == true
? versionExtractionWholePageString
: relDecoded);
version ??= additionalSettings['defaultPseudoVersioningMethod'] ==
'APKLinkHash'
? rel.hashCode.toString()
: (await checkPartialDownloadHashDynamic(rel,
headers: await getRequestHeaders(additionalSettings,
forAPKDownload: true),
allowInsecure: additionalSettings['allowInsecure'] == true))
.toString();
var apkReqHeaders =
await getRequestHeaders(additionalSettings, forAPKDownload: true);
if (version == null &&
additionalSettings['defaultPseudoVersioningMethod'] == 'ETag') {
version = await checkETagHeader(rel,
headers: apkReqHeaders,
allowInsecure: additionalSettings['allowInsecure'] == true);
if (version == null) {
throw NoVersionError();
}
}
version ??=
additionalSettings['defaultPseudoVersioningMethod'] == 'APKLinkHash'
? rel.hashCode.toString()
: (await checkPartialDownloadHashDynamic(rel,
headers: apkReqHeaders,
allowInsecure: additionalSettings['allowInsecure'] == true))
.toString();
return APKDetails(
version,
[rel].map((e) {
Expand Down
1 change: 1 addition & 0 deletions lib/main.dart
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ List<MapEntry<Locale, String>> supportedLocales = const [
'Esperanto'), // https://github.com/aissat/easy_localization/issues/220#issuecomment-846035493
MapEntry(Locale('in'), 'Bahasa Indonesia'),
MapEntry(Locale('ko'), '한국어'),
MapEntry(Locale('ca'), 'Català'),
];
const fallbackLocale = Locale('en');
const localeDir = 'assets/translations';
Expand Down
6 changes: 4 additions & 2 deletions lib/pages/add_app.dart
Original file line number Diff line number Diff line change
Expand Up @@ -575,8 +575,10 @@ class AddAppPageState extends State<AddAppPage> {

Widget getSourcesListWidget() => Padding(
padding: const EdgeInsets.all(16),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
child: Wrap(
direction: Axis.horizontal,
alignment: WrapAlignment.spaceBetween,
spacing: 12,
children: [
GestureDetector(
onTap: () {
Expand Down
16 changes: 16 additions & 0 deletions lib/providers/apps_provider.dart
Original file line number Diff line number Diff line change
Expand Up @@ -220,6 +220,22 @@ Future<String> checkPartialDownloadHash(String url, int bytesToGrab,
return hashListOfLists(bytes);
}

Future<String?> checkETagHeader(String url,
{Map<String, String>? headers, bool allowInsecure = false}) async {
// Send the initial request but cancel it as soon as you have the headers
var reqHeaders = headers ?? {};
var req = Request('GET', Uri.parse(url));
req.headers.addAll(reqHeaders);
var client = IOClient(createHttpClient(allowInsecure));
StreamedResponse response = await client.send(req);
var resHeaders = response.headers;
client.close();
return resHeaders[HttpHeaders.etagHeader]
?.replaceAll('"', '')
.hashCode
.toString();
}

Future<File> downloadFile(String url, String fileName, bool fileNameHasExt,
Function? onProgress, String destDir,
{bool useExisting = true,
Expand Down
68 changes: 54 additions & 14 deletions lib/providers/source_provider.dart
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,13 @@

import 'dart:convert';
import 'dart:io';
import 'package:http/http.dart' as http;
import 'dart:typed_data';

import 'package:device_info_plus/device_info_plus.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:html/dom.dart';
import 'package:http/http.dart';
import 'package:http/io_client.dart';
import 'package:obtainium/app_sources/apkmirror.dart';
import 'package:obtainium/app_sources/apkpure.dart';
import 'package:obtainium/app_sources/aptoide.dart';
Expand Down Expand Up @@ -566,23 +567,62 @@ abstract class AppSource {
String url, Map<String, dynamic> additionalSettings,
{bool followRedirects = true, Object? postBody}) async {
var requestHeaders = await getRequestHeaders(additionalSettings);

if (requestHeaders != null || followRedirects == false) {
var req = Request(postBody == null ? 'GET' : 'POST', Uri.parse(url));
req.followRedirects = followRedirects;
if (requestHeaders != null) {
req.headers.addAll(requestHeaders);
}
if (postBody != null) {
req.headers[HttpHeaders.contentTypeHeader] = 'application/json';
req.body = jsonEncode(postBody);
var method = postBody == null ? 'GET' : 'POST';
var currentUrl = url;
var redirectCount = 0;
const maxRedirects = 10;
while (redirectCount < maxRedirects) {
var httpClient =
createHttpClient(additionalSettings['allowInsecure'] == true);
var request = await httpClient.openUrl(method, Uri.parse(currentUrl));
if (requestHeaders != null) {
requestHeaders.forEach((key, value) {
request.headers.set(key, value);
});
}
request.followRedirects = false;
if (postBody != null) {
request.headers.contentType = ContentType.json;
request.write(jsonEncode(postBody));
}
final response = await request.close();

if (followRedirects &&
(response.statusCode == 301 || response.statusCode == 302)) {
final location = response.headers.value(HttpHeaders.locationHeader);
if (location != null) {
currentUrl = location;
redirectCount++;
httpClient.close();
continue;
}
}

final bytes = (await response.fold<BytesBuilder>(
BytesBuilder(), (b, d) => b..add(d)))
.toBytes();

final headers = <String, String>{};
response.headers.forEach((name, values) {
headers[name] = values.join(', ');
});

httpClient.close();

return http.Response.bytes(
bytes,
response.statusCode,
headers: headers,
request: http.Request(method, Uri.parse(url)),
);
}
return Response.fromStream(await IOClient(
createHttpClient(additionalSettings['allowInsecure'] == true))
.send(req));
throw ObtainiumError('Too many redirects ($maxRedirects)');
} else {
return postBody == null
? get(Uri.parse(url))
: post(Uri.parse(url), body: jsonEncode(postBody));
? http.get(Uri.parse(url))
: http.post(Uri.parse(url), body: jsonEncode(postBody));
}
}

Expand Down
16 changes: 8 additions & 8 deletions pubspec.lock
Original file line number Diff line number Diff line change
Expand Up @@ -304,10 +304,10 @@ packages:
dependency: "direct main"
description:
name: file_picker
sha256: "09b474c0c8117484b80cbebc043801ff91e05cfbd2874d512825c899e1754694"
sha256: "36a1652d99cb6bf8ccc8b9f43aded1fd60b234d23ce78af422c07f950a436ef7"
url: "https://pub.dev"
source: hosted
version: "9.2.3"
version: "10.0.0"
fixnum:
dependency: transitive
description:
Expand Down Expand Up @@ -490,10 +490,10 @@ packages:
dependency: "direct main"
description:
name: flutter_markdown
sha256: e7bbc718adc9476aa14cfddc1ef048d2e21e4e8f18311aaac723266db9f9e7b5
sha256: "634622a3a826d67cb05c0e3e576d1812c430fa98404e95b60b131775c73d76ec"
url: "https://pub.dev"
source: hosted
version: "0.7.6+2"
version: "0.7.7"
flutter_plugin_android_lifecycle:
dependency: transitive
description:
Expand Down Expand Up @@ -1107,10 +1107,10 @@ packages:
dependency: transitive
description:
name: url_launcher_ios
sha256: "16a513b6c12bb419304e72ea0ae2ab4fed569920d1c7cb850263fe3acc824626"
sha256: "7f2022359d4c099eea7df3fdf739f7d3d3b9faf3166fb1dd390775176e0b76cb"
url: "https://pub.dev"
source: hosted
version: "6.3.2"
version: "6.3.3"
url_launcher_linux:
dependency: transitive
description:
Expand Down Expand Up @@ -1211,10 +1211,10 @@ packages:
dependency: transitive
description:
name: webview_flutter_wkwebview
sha256: c49a98510080378b1525132f407a92c3dcd3b7145bef04fb8137724aadcf1cf0
sha256: c14455137ce60a68e1ccaf4e8f2dae8cebcb3465ddaa2fcfb57584fb7c5afe4d
url: "https://pub.dev"
source: hosted
version: "3.18.4"
version: "3.18.5"
win32:
dependency: transitive
description:
Expand Down
4 changes: 2 additions & 2 deletions pubspec.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,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: 1.1.48+2305
version: 1.1.49+2306

environment:
sdk: ^3.6.0
Expand Down Expand Up @@ -47,7 +47,7 @@ dependencies:
permission_handler: ^11.0.0
fluttertoast: ^8.0.9
device_info_plus: ^11.0.0
file_picker: ^9.0.0
file_picker: ^10.0.0
animations: ^2.0.4
android_package_installer: # TODO: See if PR will be accepted (dev may not be active), else remove this comment
git:
Expand Down