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

feat(package_info_plus): add install time #3434

Open
wants to merge 6 commits into
base: main
Choose a base branch
from
Open
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
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ class PackageInfoPlugin : MethodCallHandler, FlutterPlugin {
val buildSignature = getBuildSignature(packageManager)

val installerPackage = getInstallerPackageName()
val installTimeMillis = getInstallTimeMillis()

val infoMap = HashMap<String, String>()
infoMap.apply {
Expand All @@ -48,6 +49,7 @@ class PackageInfoPlugin : MethodCallHandler, FlutterPlugin {
put("buildNumber", getLongVersionCode(info).toString())
if (buildSignature != null) put("buildSignature", buildSignature)
if (installerPackage != null) put("installerStore", installerPackage)
if (installTimeMillis != null) put("installTime", installTimeMillis.toString())
}.also { resultingMap ->
result.success(resultingMap)
}
Expand All @@ -74,6 +76,22 @@ class PackageInfoPlugin : MethodCallHandler, FlutterPlugin {
}
}

private fun getInstallTimeMillis(): Long? {
return try {
val packageManager = applicationContext!!.packageManager
val packageName = applicationContext!!.packageName
val packageInfo = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
packageManager.getPackageInfo(packageName, PackageManager.PackageInfoFlags.of(0))
} else {
packageManager.getPackageInfo(packageName, 0)
}

packageInfo.firstInstallTime
} catch (e: PackageManager.NameNotFoundException) {
null
}
}

@Suppress("deprecation")
private fun getLongVersionCode(info: PackageInfo): Long {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ const android14SDK = 34;
void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();

final testStartTime = DateTime.now();

testWidgets('fromPlatform', (WidgetTester tester) async {
final info = await PackageInfo.fromPlatform();
// These tests are based on the example app. The tests should be updated if any related info changes.
Expand All @@ -26,6 +28,7 @@ void main() {
expect(info.packageName, 'package_info_plus_example');
expect(info.version, '1.2.3');
expect(info.installerStore, null);
expect(info.installTime, null);
} else {
if (Platform.isAndroid) {
final androidVersionInfo = await DeviceInfoPlugin().androidInfo;
Expand All @@ -41,33 +44,73 @@ void main() {
} else {
expect(info.installerStore, null);
}
expect(
info.installTime,
isA<DateTime>().having(
(d) => d.difference(DateTime.now()).inMinutes,
'Was just installed',
lessThanOrEqualTo(1),
),
);
} else if (Platform.isIOS) {
expect(info.appName, 'Package Info Plus Example');
expect(info.buildNumber, '4');
expect(info.buildSignature, isEmpty);
expect(info.packageName, 'io.flutter.plugins.packageInfoExample');
expect(info.version, '1.2.3');
expect(info.installerStore, 'com.apple.simulator');
expect(
info.installTime,
isA<DateTime>().having(
(d) => d.difference(DateTime.now()).inMinutes,
'Was just installed',
lessThanOrEqualTo(1),
),
);
} else if (Platform.isMacOS) {
expect(info.appName, 'Package Info Plus Example');
expect(info.buildNumber, '4');
expect(info.buildSignature, isEmpty);
expect(info.packageName, 'io.flutter.plugins.packageInfoExample');
expect(info.version, '1.2.3');
expect(info.installerStore, null);
expect(
info.installTime,
isA<DateTime>().having(
(d) => d.difference(DateTime.now()).inMinutes,
'Was just installed',
lessThanOrEqualTo(1),
),
);
} else if (Platform.isLinux) {
expect(info.appName, 'package_info_plus_example');
expect(info.buildNumber, '4');
expect(info.buildSignature, isEmpty);
expect(info.packageName, 'package_info_plus_example');
expect(info.version, '1.2.3');
expect(
info.installTime,
isA<DateTime>().having(
(d) => d.difference(DateTime.now()).inMinutes,
'Was just installed',
lessThanOrEqualTo(1),
),
);
} else if (Platform.isWindows) {
expect(info.appName, 'example');
expect(info.buildNumber, '4');
expect(info.buildSignature, isEmpty);
expect(info.packageName, 'example');
expect(info.version, '1.2.3');
expect(info.installerStore, null);
expect(
info.installTime,
isA<DateTime>().having(
(d) => d.difference(DateTime.now()).inMinutes,
'Was just installed',
lessThanOrEqualTo(1),
),
);
} else {
throw (UnsupportedError('platform not supported'));
}
Expand All @@ -83,7 +126,16 @@ void main() {
expect(find.text('4'), findsOneWidget);
expect(find.text('Not set'), findsOneWidget);
expect(find.text('not available'), findsOneWidget);
expect(find.text('Install time not available'), findsOneWidget);
} else {
final expectedInstallTimeIso = testStartTime.toIso8601String();
final installTimeRegex = RegExp(
expectedInstallTimeIso.replaceAll(
RegExp(r'\d:\d\d\..+$'),
r'.+$',
),
);

if (Platform.isAndroid) {
final androidVersionInfo = await DeviceInfoPlugin().androidInfo;

Expand All @@ -101,6 +153,7 @@ void main() {
} else {
expect(find.text('not available'), findsOneWidget);
}
expect(find.textContaining(installTimeRegex), findsOneWidget);
} else if (Platform.isIOS) {
expect(find.text('Package Info Plus Example'), findsOneWidget);
expect(find.text('4'), findsOneWidget);
Expand All @@ -109,6 +162,7 @@ void main() {
expect(find.text('1.2.3'), findsOneWidget);
expect(find.text('Not set'), findsOneWidget);
expect(find.text('com.apple.simulator'), findsOneWidget);
expect(find.textContaining(installTimeRegex), findsOneWidget);
} else if (Platform.isMacOS) {
expect(find.text('Package Info Plus Example'), findsOneWidget);
expect(find.text('4'), findsOneWidget);
Expand All @@ -117,17 +171,20 @@ void main() {
expect(find.text('1.2.3'), findsOneWidget);
expect(find.text('Not set'), findsOneWidget);
expect(find.text('not available'), findsOneWidget);
expect(find.textContaining(installTimeRegex), findsOneWidget);
} else if (Platform.isLinux) {
expect(find.text('package_info_plus_example'), findsNWidgets(2));
expect(find.text('1.2.3'), findsOneWidget);
expect(find.text('4'), findsOneWidget);
expect(find.text('Not set'), findsOneWidget);
expect(find.textContaining(installTimeRegex), findsOneWidget);
} else if (Platform.isWindows) {
expect(find.text('example'), findsNWidgets(2));
expect(find.text('1.2.3'), findsOneWidget);
expect(find.text('4'), findsOneWidget);
expect(find.text('Not set'), findsOneWidget);
expect(find.text('not available'), findsOneWidget);
expect(find.textContaining(installTimeRegex), findsOneWidget);
} else {
throw (UnsupportedError('platform not supported'));
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,11 @@ class _MyHomePageState extends State<MyHomePage> {
'Installer store',
_packageInfo.installerStore ?? 'not available',
),
_infoTile(
'Install time',
_packageInfo.installTime?.toIso8601String() ??
'Install time not available',
),
],
),
);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
name: package_info_plus_example
description: Demonstrates how to use the package_info_plus plugin.
version: 1.2.3+4
publish_to: 'none'
publish_to: "none"

environment:
sdk: '>=2.18.0 <4.0.0'
sdk: ">=2.18.0 <4.0.0"

dependencies:
clock: ^1.1.1
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,12 @@ - (void)handleMethodCall:(FlutterMethodCall *)call
? @"com.apple.testflight"
: @"com.apple";

NSURL* urlToDocumentsFolder = [[[NSFileManager defaultManager] URLsForDirectory:NSDocumentDirectory inDomains:NSUserDomainMask] lastObject];
__autoreleasing NSError *error;
NSDate *installDate = [[[NSFileManager defaultManager] attributesOfItemAtPath:urlToDocumentsFolder.path error:&error] objectForKey:NSFileCreationDate];
NSNumber *installTimeMillis = installDate ? @((long long)([installDate timeIntervalSince1970] * 1000)) : [NSNull null];


result(@{
@"appName" : [[NSBundle mainBundle]
objectForInfoDictionaryKey:@"CFBundleDisplayName"]
Expand All @@ -39,8 +45,10 @@ - (void)handleMethodCall:(FlutterMethodCall *)call
@"buildNumber" : [[NSBundle mainBundle]
objectForInfoDictionaryKey:@"CFBundleVersion"]
?: [NSNull null],
@"installerStore" : installerStore
@"installerStore" : installerStore,
@"installTime" : installTimeMillis ? [installTimeMillis stringValue] : [NSNull null]
});

} else {
result(FlutterMethodNotImplemented);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ class PackageInfo {
required this.buildNumber,
this.buildSignature = '',
this.installerStore,
this.installTime,
});

static PackageInfo? _fromPlatform;
Expand Down Expand Up @@ -88,6 +89,7 @@ class PackageInfo {
buildNumber: platformData.buildNumber,
buildSignature: platformData.buildSignature,
installerStore: platformData.installerStore,
installTime: platformData.installTime,
);
return _fromPlatform!;
}
Expand Down Expand Up @@ -147,6 +149,16 @@ class PackageInfo {
/// The installer store. Indicates through which store this application was installed.
final String? installerStore;

/// The time when the application was installed.
///
/// - On Android, returns `PackageManager.firstInstallTime`
/// - On iOS and macOS, return the creation date of the app default `NSDocumentDirectory`
/// - On Windows and Linux, returns the creation date of the app executable.
/// If the creation date is not available, returns the last modified date of the app executable.
/// If the last modified date is not available, returns `null`.
/// - On web, returns `null`.
final DateTime? installTime;

/// Initializes the application metadata with mock values for testing.
///
/// If the singleton instance has been initialized already, it is overwritten.
Expand All @@ -158,6 +170,7 @@ class PackageInfo {
required String buildNumber,
required String buildSignature,
String? installerStore,
DateTime? installTime,
}) {
_fromPlatform = PackageInfo(
appName: appName,
Expand All @@ -166,6 +179,7 @@ class PackageInfo {
buildNumber: buildNumber,
buildSignature: buildSignature,
installerStore: installerStore,
installTime: installTime,
);
}

Expand All @@ -180,7 +194,8 @@ class PackageInfo {
version == other.version &&
buildNumber == other.buildNumber &&
buildSignature == other.buildSignature &&
installerStore == other.installerStore;
installerStore == other.installerStore &&
installTime == other.installTime;

/// Overwrite hashCode for value equality
@override
Expand All @@ -190,11 +205,12 @@ class PackageInfo {
version.hashCode ^
buildNumber.hashCode ^
buildSignature.hashCode ^
installerStore.hashCode;
installerStore.hashCode ^
installTime.hashCode;

@override
String toString() {
return 'PackageInfo(appName: $appName, buildNumber: $buildNumber, packageName: $packageName, version: $version, buildSignature: $buildSignature, installerStore: $installerStore)';
return 'PackageInfo(appName: $appName, buildNumber: $buildNumber, packageName: $packageName, version: $version, buildSignature: $buildSignature, installerStore: $installerStore, installTime: $installTime)';
}

Map<String, dynamic> _toMap() {
Expand All @@ -205,6 +221,7 @@ class PackageInfo {
'version': version,
if (buildSignature.isNotEmpty) 'buildSignature': buildSignature,
if (installerStore?.isNotEmpty ?? false) 'installerStore': installerStore,
if (installTime != null) 'installTime': installTime
};
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import 'dart:ffi';
import 'dart:io';

import 'package:ffi/ffi.dart';
import 'package:win32/win32.dart';

base class FILEATTRIBUTEDATA extends Struct {
@DWORD()
external int dwFileAttributes;

external FILETIME ftCreationTime;

external FILETIME ftLastAccessTime;

external FILETIME ftLastWriteTime;

@DWORD()
external int nFileSizeHigh;

@DWORD()
external int nFileSizeLow;
}

class FileAttributes {
final String filePath;

late final DateTime? creationTime;
late final DateTime? lastWriteTime;

FileAttributes(this.filePath) {
final (:creationTime, :lastWriteTime) =
getFileCreationAndLastWriteTime(filePath);

this.creationTime = creationTime;
this.lastWriteTime = lastWriteTime;
}

static ({
DateTime? creationTime,
DateTime? lastWriteTime,
}) getFileCreationAndLastWriteTime(String filePath) {
if (!File(filePath).existsSync()) {
throw ArgumentError.value(filePath, 'filePath', 'File not present');
}

final lptstrFilename = TEXT(filePath);
final lpFileInformation = calloc<FILEATTRIBUTEDATA>();

try {
if (GetFileAttributesEx(lptstrFilename, 0, lpFileInformation) == 0) {
throw WindowsException(HRESULT_FROM_WIN32(GetLastError()));
}

final FILEATTRIBUTEDATA fileInformation = lpFileInformation.ref;

return (
creationTime: fileTimeToDartDateTime(
fileInformation.ftCreationTime,
),
lastWriteTime: fileTimeToDartDateTime(
fileInformation.ftLastWriteTime,
),
);
} finally {
free(lptstrFilename);
free(lpFileInformation);
}
}

static DateTime? fileTimeToDartDateTime(FILETIME? fileTime) {
if (fileTime == null) return null;

final high = fileTime.dwHighDateTime;
final low = fileTime.dwLowDateTime;

final fileTime64 = (high << 32) + low;

final windowsTimeMillis = fileTime64 ~/ 10000;
final unixTimeMillis = windowsTimeMillis - 11644473600000;

return DateTime.fromMillisecondsSinceEpoch(unixTimeMillis);
}
}
Loading
Loading