Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 14 additions & 4 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,13 @@
## 0.3.2

Behavior changes on home and search page.

### Behavior changes

- On start the app there is a throttling being applied to the render process which makes the app fps smoothier.
- Search page has now a debounce applied (of 50ms) to improve performance.
- The search algorithm is now using a [string_similarity](https://pub.dev/packages/string_similarity) algorithm to rank and display best results.

## 0.3.1

This released was focused in adding internationalization features to the app, following languages were added:
Expand All @@ -8,7 +18,7 @@ This released was focused in adding internationalization features to the app, fo
- Spanish (es).
- (Previously supported) English (en).

## New
### New

- Settings:
- Added location settings tile.
Expand All @@ -21,7 +31,7 @@ Most release changes are related to UI and design stuff, minimal changes to core

UI/UX improvements:

## New
### New

- Settings:

Expand Down Expand Up @@ -51,11 +61,11 @@ UI/UX improvements:

- Replaced gif and app bar with a minimal logo animation written using `FFF Forward` font.

## Behavior changes
### Behavior changes

- When in selection or search mode, on tap back (through arrow back or native device buttons) it now redirects to the previous state instead popping to the homepage directly.

## Bug fixes
### Bug fixes

- Fix crash when trying to export to a folder that no longer exists (probably was deleted through the system file manager or a third-party app). It now prompt the user to select a new location.

Expand Down
12 changes: 8 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ PlayStore link, add again when the new version is available.

## What's an apk extractor

Installed apps from PlayStore doesn't expose their apk installation files by default. So if you want to share some app you will need to send the PlayStore link. And if you are offline or the app is no longer available on the PlayStore you won't be able to share the app.
Installed apps from PlayStore doesn't expose their apk installation files by default. So if you want to share some app you will need to send the PlayStore link, and if you are offline or the app is no longer available on the PlayStore you won't be able to do so.

So here we are, whatever the reason, if you want to share some app directly through a p2p connection (Bluetooth, Wifi-Direct, etc.) you can use apk extractors! These kind of apps allow the user to extract the hidden apk files from almost any installed app to a visible location (e.g Downloads folder).

Expand Down Expand Up @@ -130,11 +130,15 @@ To display all installed apps the [`🔗 device_apps`](https://pub.dev/packages/

There are several ways to contribute:

- To improve the translation, open the `/i18n` folder create a file `app_<thelangcodeyouwanttoaddorimprove>.arb` and translate the keys, then open a PR, that's it.
- To improve the translation: 1. open the `/i18n` folder, 2. create a file `app_<thelangcodeyouwanttoaddorimprove>.arb` and 3. translate the keys 4. then open a PR, that's it.

- To report a bug, create a new issue with an screenshot or a small description of the bug, thanks.
- To report a bug, create a new issue with an screenshot or a small description of the bug.

- To request a feature please add an issue to further discuss, thx.
- To request a feature please add an issue to further discuss.

If you wanna contribute in private, you can also ping me on my email [[email protected]](mailto://[email protected]) to discuss any of the points above.

Thanks in advance.

## Contributors

Expand Down
18 changes: 13 additions & 5 deletions lib/pages/home_page.dart
Original file line number Diff line number Diff line change
Expand Up @@ -51,11 +51,19 @@ class _HomePageState extends State<HomePage>
return state.clamp(0, 1);
}

return LinearProgressIndicator(
minHeight: k2dp,
color: context.theme.primaryColor,
backgroundColor: context.theme.cardColor,
value: isDeterminatedState ? progress() : null,
return TweenAnimationBuilder<double>(
duration: const Duration(milliseconds: 1000),
curve: Curves.easeInOut,
tween: Tween<double>(
begin: 0,
end: isDeterminatedState ? progress() : 0,
),
builder: (context, value, _) => LinearProgressIndicator(
minHeight: k2dp,
color: context.theme.primaryColor,
backgroundColor: context.theme.cardColor,
value: value,
),
);
}

Expand Down
123 changes: 88 additions & 35 deletions lib/stores/device_apps.dart
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,13 @@ import 'package:device_apps/device_apps.dart';
import 'package:flutter/material.dart';
import 'package:kanade/setup.dart';
import 'package:kanade/stores/settings.dart';
import 'package:kanade/utils/debounce.dart';
import 'package:kanade/utils/is_disposed_mixin.dart';
import 'package:kanade/utils/stringify_uri_location.dart';
import 'package:kanade/utils/throttle.dart';
import 'package:nanoid/async.dart';
import 'package:shared_storage/shared_storage.dart';
import 'package:string_similarity/string_similarity.dart';

mixin DeviceAppsStoreConsumer<T extends StatefulWidget> on State<T> {
DeviceAppsStore? _store;
Expand Down Expand Up @@ -101,7 +105,59 @@ class MultipleResult {
bool get permissionWasDenied => value == 3;
}

class DeviceAppsStore extends ChangeNotifier {
class ApplicationSearchResult implements Comparable<ApplicationSearchResult> {
const ApplicationSearchResult({required this.app, required this.text});

final String text;
final Application app;

String get _rawRegex {
final matcher = text.substring(0, text.length - 1).split('').join('.*');
final ending = text[text.length - 1];

return matcher + ending;
}

RegExp get _regex => RegExp(_rawRegex, caseSensitive: false);

/// Checks if [source] contains all the characters of [text] in the correct order
///
/// Example:
/// ```
/// hasMatch('abcdef', 'adf') // true
/// hasMatch('dbcaef', 'adf') // false
/// ```
bool _hasWildcardMatch() {
return _regex.hasMatch(source);
}

bool hasMatch() {
return _hasWildcardMatch();
}

String get source {
return [app.appName, app.packageName].join(' ').toLowerCase();
}

double get similarity {
return text.similarityTo(source);
}

@override
int compareTo(ApplicationSearchResult other) {
if (text != other.text) return 0;

if (similarity == other.similarity) {
return 0;
} else if (similarity > other.similarity) {
return 1;
} else {
return -1;
}
}
}

class DeviceAppsStore extends ChangeNotifier with IsDisposedMixin {
/// Id length to avoid filename conflict on extract Apk
static const kIdLength = 5;

Expand All @@ -116,19 +172,15 @@ class DeviceAppsStore extends ChangeNotifier {
/// List of all selected applications
final selected = <Application>{};

/// List of all search results
/// If null, has no query
/// If empty, has no results
/// Otherwise hold all results
List<Application>? results;

/// Whether loading device applications or not
bool isLoading = false;
int? totalPackagesCount;
int? get loadedPackagesCount => isLoading ? apps.length : totalPackagesCount;
bool get fullyLoaded =>
!isLoading && loadedPackagesCount == totalPackagesCount;

void Function(void Function()) throttle = throttleIt500ms();

/// Load all device packages
///
/// You need call this method before any action
Expand All @@ -148,7 +200,11 @@ class DeviceAppsStore extends ChangeNotifier {
(app) {
apps.add(app);

notifyListeners();
throttle(() {
if (!isDisposed) {
notifyListeners();
}
});
},
onDone: () {
isLoading = false;
Expand All @@ -170,7 +226,8 @@ class DeviceAppsStore extends ChangeNotifier {
}

/// Packages to be rendered on the screen
List<Application> get displayableApps => results != null ? results! : apps;
List<Application> get displayableApps =>
_searchText != null ? results.map((e) => e.app).toList() : apps;

/// Return [true] if all [displayableApps] are selected
bool get isAllSelected => displayableApps.length == selected.length;
Expand Down Expand Up @@ -250,9 +307,8 @@ class DeviceAppsStore extends ChangeNotifier {
/// Verify if a given [package] is selected
bool isSelected(Application package) => selected.contains(package);

/// Set [results] as [null] and show all [apps] as [displayableApps]
void disableSearch() {
results = null;
_searchText = null;
notifyListeners();
}

Expand All @@ -269,37 +325,34 @@ class DeviceAppsStore extends ChangeNotifier {
notifyListeners();
}

bool get isSearchMode => _searchText != null;

List<ApplicationSearchResult> get results {
if (_searchText == null) return [];

final filtered = apps
.map((app) => ApplicationSearchResult(app: app, text: _searchText!))
.where((result) => result.hasMatch())
.toList()
..sort((a, z) => z.compareTo(a));

return filtered;
}

String? _searchText;

final debounceSearch = debounceIt50ms();

/// Add all matched apps to [results] array if any
///
/// This method will disable search if [text] is empty by default
void search(String text) {
bool hasMatch(Application app) {
final source = [app.appName, app.packageName].join(' ').toLowerCase();

return _hasWildcardMatch(source, text.toLowerCase());
}

results = [];
_searchText = text;

if (text.isEmpty) {
disableSearch();
} else {
results = apps.where(hasMatch).toList();
_searchText = null;
}

notifyListeners();
}

/// Checks if [source] contains all the characters of [text] in the correct order
///
/// Example:
/// ```
/// hasMatch('abcdef', 'adf') // true
/// hasMatch('dbcaef', 'adf') // false
/// ```
bool _hasWildcardMatch(String source, String text) {
final regexp = text.split('').join('.*');

return RegExp(regexp).hasMatch(source);
debounceSearch(() => notifyListeners());
}
}
21 changes: 21 additions & 0 deletions lib/utils/debounce.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import 'dart:async';

void Function(void Function()) debounceIt200ms() {
return debounceIt(const Duration(milliseconds: 200));
}

void Function(void Function()) debounceIt50ms() {
return debounceIt(const Duration(milliseconds: 50));
}

void Function(void Function()) debounceIt(Duration duration) {
Timer? debounce;

return (fn) {
debounce?.cancel();

void callback() => ({fn(), debounce?.cancel()});

debounce = Timer(duration, callback);
};
}
12 changes: 12 additions & 0 deletions lib/utils/is_disposed_mixin.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import 'package:flutter/cupertino.dart';

mixin IsDisposedMixin on ChangeNotifier {
bool isDisposed = false;

@override
void dispose() {
isDisposed = true;

super.dispose();
}
}
36 changes: 36 additions & 0 deletions lib/utils/throttle.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import 'dart:async';

/// Do not declare it directly like this:
/// ```dart
/// final kThrottle5000ms = throttleIt(const Duration(milliseconds: 5000));
/// ```
/// because any global call will reset the throttle function.
void Function(void Function()) throttleIt1s() {
return throttleIt(const Duration(seconds: 1));
}

void Function(void Function()) throttleIt500ms() {
return throttleIt(const Duration(milliseconds: 500));
}

void Function(void Function()) throttleIt(Duration duration) {
Timer? throttle;

var allowExec = true;

void resetThrottle(void Function() fn) {
allowExec = false;

void callback() => {allowExec = true, throttle?.cancel()};

throttle = Timer(duration, callback);

fn();
}

return (fn) {
if (!allowExec) return;

resetThrottle(fn);
};
}
Loading