From d3c11877b4c661936b70fbe33ed55e30094542dc Mon Sep 17 00:00:00 2001 From: Andrew Bekhiet <40571928+Andrew-Bekhiet@users.noreply.github.com> Date: Wed, 15 Jan 2025 22:52:13 +0200 Subject: [PATCH 1/5] feat(package_info_plus): add installTime --- .../plus/packageinfo/PackageInfoPlugin.kt | 18 ++++++++++ .../package_info_plus_test.dart | 36 +++++++++++++++++++ .../package_info_plus/example/lib/main.dart | 5 +++ .../package_info_plus/example/pubspec.yaml | 4 +-- .../FPPPackageInfoPlusPlugin.m | 10 +++++- .../lib/package_info_plus.dart | 20 +++++++++-- .../test/package_info_test.dart | 32 ++++++++++++----- .../lib/method_channel_package_info.dart | 7 ++++ .../lib/package_info_data.dart | 4 +++ 9 files changed, 122 insertions(+), 14 deletions(-) diff --git a/packages/package_info_plus/package_info_plus/android/src/main/kotlin/dev/fluttercommunity/plus/packageinfo/PackageInfoPlugin.kt b/packages/package_info_plus/package_info_plus/android/src/main/kotlin/dev/fluttercommunity/plus/packageinfo/PackageInfoPlugin.kt index 76bbe089b5..b1e6870ef3 100644 --- a/packages/package_info_plus/package_info_plus/android/src/main/kotlin/dev/fluttercommunity/plus/packageinfo/PackageInfoPlugin.kt +++ b/packages/package_info_plus/package_info_plus/android/src/main/kotlin/dev/fluttercommunity/plus/packageinfo/PackageInfoPlugin.kt @@ -39,6 +39,7 @@ class PackageInfoPlugin : MethodCallHandler, FlutterPlugin { val buildSignature = getBuildSignature(packageManager) val installerPackage = getInstallerPackageName() + val installTimeMillis = getInstallTimeMillis() val infoMap = HashMap() infoMap.apply { @@ -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) } @@ -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) { diff --git a/packages/package_info_plus/package_info_plus/example/integration_test/package_info_plus_test.dart b/packages/package_info_plus/package_info_plus/example/integration_test/package_info_plus_test.dart index 7be42d7696..e39601e11e 100644 --- a/packages/package_info_plus/package_info_plus/example/integration_test/package_info_plus_test.dart +++ b/packages/package_info_plus/package_info_plus/example/integration_test/package_info_plus_test.dart @@ -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. @@ -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; @@ -41,6 +44,14 @@ void main() { } else { expect(info.installerStore, null); } + expect( + info.installTime, + isA().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'); @@ -48,6 +59,14 @@ void main() { expect(info.packageName, 'io.flutter.plugins.packageInfoExample'); expect(info.version, '1.2.3'); expect(info.installerStore, 'com.apple.simulator'); + expect( + info.installTime, + isA().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'); @@ -55,12 +74,14 @@ void main() { expect(info.packageName, 'io.flutter.plugins.packageInfoExample'); expect(info.version, '1.2.3'); expect(info.installerStore, null); + expect(info.installTime, null); } 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, null); } else if (Platform.isWindows) { expect(info.appName, 'example'); expect(info.buildNumber, '4'); @@ -68,6 +89,7 @@ void main() { expect(info.packageName, 'example'); expect(info.version, '1.2.3'); expect(info.installerStore, null); + expect(info.installTime, null); } else { throw (UnsupportedError('platform not supported')); } @@ -83,7 +105,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; @@ -101,6 +132,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); @@ -109,6 +141,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); @@ -117,17 +150,20 @@ void main() { expect(find.text('1.2.3'), findsOneWidget); expect(find.text('Not set'), findsOneWidget); expect(find.text('not available'), findsOneWidget); + expect(find.text('Install time not available'), 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.text('Install time not available'), 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.text('Install time not available'), findsOneWidget); } else { throw (UnsupportedError('platform not supported')); } diff --git a/packages/package_info_plus/package_info_plus/example/lib/main.dart b/packages/package_info_plus/package_info_plus/example/lib/main.dart index 74e092de80..9d3f5890b2 100644 --- a/packages/package_info_plus/package_info_plus/example/lib/main.dart +++ b/packages/package_info_plus/package_info_plus/example/lib/main.dart @@ -84,6 +84,11 @@ class _MyHomePageState extends State { 'Installer store', _packageInfo.installerStore ?? 'not available', ), + _infoTile( + 'Install time', + _packageInfo.installTime?.toIso8601String() ?? + 'Install time not available', + ), ], ), ); diff --git a/packages/package_info_plus/package_info_plus/example/pubspec.yaml b/packages/package_info_plus/package_info_plus/example/pubspec.yaml index 62641e1444..9315f64d99 100644 --- a/packages/package_info_plus/package_info_plus/example/pubspec.yaml +++ b/packages/package_info_plus/package_info_plus/example/pubspec.yaml @@ -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 diff --git a/packages/package_info_plus/package_info_plus/ios/package_info_plus/Sources/package_info_plus/FPPPackageInfoPlusPlugin.m b/packages/package_info_plus/package_info_plus/ios/package_info_plus/Sources/package_info_plus/FPPPackageInfoPlusPlugin.m index 249bfebbfb..eaa3ff8edf 100644 --- a/packages/package_info_plus/package_info_plus/ios/package_info_plus/Sources/package_info_plus/FPPPackageInfoPlusPlugin.m +++ b/packages/package_info_plus/package_info_plus/ios/package_info_plus/Sources/package_info_plus/FPPPackageInfoPlusPlugin.m @@ -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"] @@ -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); } diff --git a/packages/package_info_plus/package_info_plus/lib/package_info_plus.dart b/packages/package_info_plus/package_info_plus/lib/package_info_plus.dart index 7004c5ada6..4a57945f66 100644 --- a/packages/package_info_plus/package_info_plus/lib/package_info_plus.dart +++ b/packages/package_info_plus/package_info_plus/lib/package_info_plus.dart @@ -27,6 +27,7 @@ class PackageInfo { required this.buildNumber, this.buildSignature = '', this.installerStore, + this.installTime, }); static PackageInfo? _fromPlatform; @@ -88,6 +89,7 @@ class PackageInfo { buildNumber: platformData.buildNumber, buildSignature: platformData.buildSignature, installerStore: platformData.installerStore, + installTime: platformData.installTime, ); return _fromPlatform!; } @@ -147,6 +149,13 @@ class PackageInfo { /// The installer store. Indicates through which store this application was installed. final String? installerStore; + /// The time when the application was installed on the device. + /// + /// Checks the creation date of the Documents directory on iOS + /// or returns `packageInfo.firstInstallTime` on Android. + /// Otherwise 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. @@ -158,6 +167,7 @@ class PackageInfo { required String buildNumber, required String buildSignature, String? installerStore, + DateTime? installTime, }) { _fromPlatform = PackageInfo( appName: appName, @@ -166,6 +176,7 @@ class PackageInfo { buildNumber: buildNumber, buildSignature: buildSignature, installerStore: installerStore, + installTime: installTime, ); } @@ -180,7 +191,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 @@ -190,11 +202,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 _toMap() { @@ -205,6 +218,7 @@ class PackageInfo { 'version': version, if (buildSignature.isNotEmpty) 'buildSignature': buildSignature, if (installerStore?.isNotEmpty ?? false) 'installerStore': installerStore, + if (installTime != null) 'installTime': installTime }; } diff --git a/packages/package_info_plus/package_info_plus/test/package_info_test.dart b/packages/package_info_plus/package_info_plus/test/package_info_test.dart index af7bb1040e..b81bddc491 100644 --- a/packages/package_info_plus/package_info_plus/test/package_info_test.dart +++ b/packages/package_info_plus/package_info_plus/test/package_info_test.dart @@ -12,6 +12,8 @@ void main() { const channel = MethodChannel('dev.fluttercommunity.plus/package_info'); final log = []; + final now = DateTime.now().copyWith(microsecond: 0); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger .setMockMethodCallHandler( channel, @@ -25,6 +27,7 @@ void main() { 'packageName': 'io.flutter.plugins.packageinfoexample', 'version': '1.0', 'installerStore': null, + 'installTime': now.millisecondsSinceEpoch.toString(), }; default: assert(false); @@ -44,6 +47,7 @@ void main() { expect(info.packageName, 'io.flutter.plugins.packageinfoexample'); expect(info.version, '1.0'); expect(info.installerStore, null); + expect(info.installTime, now); expect( log, [ @@ -63,6 +67,7 @@ void main() { buildNumber: '2', buildSignature: 'deadbeef', installerStore: null, + installTime: now, ); final info = await PackageInfo.fromPlatform(); expect(info.appName, 'mock_package_info_example'); @@ -71,6 +76,7 @@ void main() { expect(info.version, '1.1'); expect(info.buildSignature, 'deadbeef'); expect(info.installerStore, null); + expect(info.installTime, now); }); test('equals checks for value equality', () async { @@ -81,6 +87,7 @@ void main() { version: '1.0', buildSignature: '', installerStore: null, + installTime: now, ); final info2 = PackageInfo( appName: 'package_info_example', @@ -89,19 +96,20 @@ void main() { version: '1.0', buildSignature: '', installerStore: null, + installTime: now, ); expect(info1, info2); }); test('hashCode checks for value equality', () async { final info1 = PackageInfo( - appName: 'package_info_example', - buildNumber: '1', - packageName: 'io.flutter.plugins.packageinfoexample', - version: '1.0', - buildSignature: '', - installerStore: null, - ); + appName: 'package_info_example', + buildNumber: '1', + packageName: 'io.flutter.plugins.packageinfoexample', + version: '1.0', + buildSignature: '', + installerStore: null, + installTime: now); final info2 = PackageInfo( appName: 'package_info_example', buildNumber: '1', @@ -109,6 +117,7 @@ void main() { version: '1.0', buildSignature: '', installerStore: null, + installTime: now, ); expect(info1.hashCode, info2.hashCode); }); @@ -121,10 +130,11 @@ void main() { version: '1.0', buildSignature: '', installerStore: null, + installTime: now, ); expect( info.toString(), - 'PackageInfo(appName: package_info_example, buildNumber: 1, packageName: io.flutter.plugins.packageinfoexample, version: 1.0, buildSignature: , installerStore: null)', + 'PackageInfo(appName: package_info_example, buildNumber: 1, packageName: io.flutter.plugins.packageinfoexample, version: 1.0, buildSignature: , installerStore: null, installTime: $now)', ); }); @@ -136,6 +146,7 @@ void main() { buildNumber: '2', buildSignature: '', installerStore: null, + installTime: now, ); final info1 = await PackageInfo.fromPlatform(); expect(info1.data, { @@ -143,7 +154,10 @@ void main() { 'packageName': 'io.flutter.plugins.mockpackageinfoexample', 'version': '1.1', 'buildNumber': '2', + 'installTime': now, }); + + final nextWeek = now.add(const Duration(days: 7)); PackageInfo.setMockInitialValues( appName: 'mock_package_info_example', packageName: 'io.flutter.plugins.mockpackageinfoexample', @@ -151,6 +165,7 @@ void main() { buildNumber: '2', buildSignature: 'deadbeef', installerStore: 'testflight', + installTime: nextWeek, ); final info2 = await PackageInfo.fromPlatform(); expect(info2.data, { @@ -160,6 +175,7 @@ void main() { 'buildNumber': '2', 'buildSignature': 'deadbeef', 'installerStore': 'testflight', + 'installTime': nextWeek, }); }); } diff --git a/packages/package_info_plus/package_info_plus_platform_interface/lib/method_channel_package_info.dart b/packages/package_info_plus/package_info_plus_platform_interface/lib/method_channel_package_info.dart index 0c609b546b..45918a8a55 100644 --- a/packages/package_info_plus/package_info_plus_platform_interface/lib/method_channel_package_info.dart +++ b/packages/package_info_plus/package_info_plus_platform_interface/lib/method_channel_package_info.dart @@ -11,6 +11,12 @@ class MethodChannelPackageInfo extends PackageInfoPlatform { @override Future getAll({String? baseUrl}) async { final map = await _channel.invokeMapMethod('getAll'); + + final installTime = map?['installTime'] != null && + int.tryParse(map!['installTime']!) != null + ? DateTime.fromMillisecondsSinceEpoch(int.parse(map['installTime']!)) + : null; + return PackageInfoData( appName: map!['appName'] ?? '', packageName: map['packageName'] ?? '', @@ -18,6 +24,7 @@ class MethodChannelPackageInfo extends PackageInfoPlatform { buildNumber: map['buildNumber'] ?? '', buildSignature: map['buildSignature'] ?? '', installerStore: map['installerStore'] as String?, + installTime: installTime, ); } } diff --git a/packages/package_info_plus/package_info_plus_platform_interface/lib/package_info_data.dart b/packages/package_info_plus/package_info_plus_platform_interface/lib/package_info_data.dart index 60c6c9f9cf..bd630587ed 100644 --- a/packages/package_info_plus/package_info_plus_platform_interface/lib/package_info_data.dart +++ b/packages/package_info_plus/package_info_plus_platform_interface/lib/package_info_data.dart @@ -10,6 +10,7 @@ class PackageInfoData { required this.buildNumber, required this.buildSignature, this.installerStore, + this.installTime, }); /// The app name. `CFBundleDisplayName` on iOS, `application/label` on Android. @@ -29,4 +30,7 @@ class PackageInfoData { /// The installer store. Indicates through which store this application was installed. final String? installerStore; + + /// The time when the application was installed. The creation date of documents directory on iOS, `firstInstallTime` on Android, null otherwise. + final DateTime? installTime; } From 4b25c5f3cd7f4dc5a04e804945efb635e6ac5bc4 Mon Sep 17 00:00:00 2001 From: Andrew Bekhiet <40571928+Andrew-Bekhiet@users.noreply.github.com> Date: Thu, 16 Jan 2025 00:00:17 +0200 Subject: [PATCH 2/5] style(package_info_plus): adjust a trailing comma --- .../package_info_plus/test/package_info_test.dart | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/packages/package_info_plus/package_info_plus/test/package_info_test.dart b/packages/package_info_plus/package_info_plus/test/package_info_test.dart index b81bddc491..a953053b61 100644 --- a/packages/package_info_plus/package_info_plus/test/package_info_test.dart +++ b/packages/package_info_plus/package_info_plus/test/package_info_test.dart @@ -103,13 +103,14 @@ void main() { test('hashCode checks for value equality', () async { final info1 = PackageInfo( - appName: 'package_info_example', - buildNumber: '1', - packageName: 'io.flutter.plugins.packageinfoexample', - version: '1.0', - buildSignature: '', - installerStore: null, - installTime: now); + appName: 'package_info_example', + buildNumber: '1', + packageName: 'io.flutter.plugins.packageinfoexample', + version: '1.0', + buildSignature: '', + installerStore: null, + installTime: now, + ); final info2 = PackageInfo( appName: 'package_info_example', buildNumber: '1', From 7c16b7b6e7fcf638219ba764af18f2a345184251 Mon Sep 17 00:00:00 2001 From: Andrew Bekhiet <40571928+Andrew-Bekhiet@users.noreply.github.com> Date: Thu, 16 Jan 2025 23:40:24 +0200 Subject: [PATCH 3/5] feat(package_info_plus): implement install time for desktop platforms --- .../package_info_plus_test.dart | 33 ++++++-- .../lib/package_info_plus.dart | 13 ++-- .../lib/src/file_attribute.dart | 75 +++++++++++++++++++ .../lib/src/package_info_plus_linux.dart | 29 ++++++- .../lib/src/package_info_plus_windows.dart | 5 +- .../FPPPackageInfoPlusPlugin.m | 8 +- .../test/package_info_plus_windows_test.dart | 31 +++++++- .../lib/package_info_data.dart | 9 ++- 8 files changed, 186 insertions(+), 17 deletions(-) create mode 100644 packages/package_info_plus/package_info_plus/lib/src/file_attribute.dart diff --git a/packages/package_info_plus/package_info_plus/example/integration_test/package_info_plus_test.dart b/packages/package_info_plus/package_info_plus/example/integration_test/package_info_plus_test.dart index e39601e11e..8023eeaa4b 100644 --- a/packages/package_info_plus/package_info_plus/example/integration_test/package_info_plus_test.dart +++ b/packages/package_info_plus/package_info_plus/example/integration_test/package_info_plus_test.dart @@ -74,14 +74,28 @@ void main() { expect(info.packageName, 'io.flutter.plugins.packageInfoExample'); expect(info.version, '1.2.3'); expect(info.installerStore, null); - expect(info.installTime, null); + expect( + info.installTime, + isA().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, null); + expect( + info.installTime, + isA().having( + (d) => d.difference(DateTime.now()).inMinutes, + 'Was just installed', + lessThanOrEqualTo(1), + ), + ); } else if (Platform.isWindows) { expect(info.appName, 'example'); expect(info.buildNumber, '4'); @@ -89,7 +103,14 @@ void main() { expect(info.packageName, 'example'); expect(info.version, '1.2.3'); expect(info.installerStore, null); - expect(info.installTime, null); + expect( + info.installTime, + isA().having( + (d) => d.difference(DateTime.now()).inMinutes, + 'Was just installed', + lessThanOrEqualTo(1), + ), + ); } else { throw (UnsupportedError('platform not supported')); } @@ -150,20 +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.text('Install time 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.text('Install time not available'), 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.text('Install time not available'), findsOneWidget); + expect(find.textContaining(installTimeRegex), findsOneWidget); } else { throw (UnsupportedError('platform not supported')); } diff --git a/packages/package_info_plus/package_info_plus/lib/package_info_plus.dart b/packages/package_info_plus/package_info_plus/lib/package_info_plus.dart index 4a57945f66..2374733d7d 100644 --- a/packages/package_info_plus/package_info_plus/lib/package_info_plus.dart +++ b/packages/package_info_plus/package_info_plus/lib/package_info_plus.dart @@ -149,11 +149,14 @@ class PackageInfo { /// The installer store. Indicates through which store this application was installed. final String? installerStore; - /// The time when the application was installed on the device. - /// - /// Checks the creation date of the Documents directory on iOS - /// or returns `packageInfo.firstInstallTime` on Android. - /// Otherwise returns null. + /// 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. diff --git a/packages/package_info_plus/package_info_plus/lib/src/file_attribute.dart b/packages/package_info_plus/package_info_plus/lib/src/file_attribute.dart new file mode 100644 index 0000000000..0dc87992d8 --- /dev/null +++ b/packages/package_info_plus/package_info_plus/lib/src/file_attribute.dart @@ -0,0 +1,75 @@ +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 attributesPtr = getFileAttributes(filePath); + + if (attributesPtr != null) { + creationTime = fileTimeToDartDateTime(attributesPtr.ref.ftCreationTime); + lastWriteTime = fileTimeToDartDateTime(attributesPtr.ref.ftLastWriteTime); + + free(attributesPtr); + } else { + creationTime = null; + lastWriteTime = null; + } + } + + static Pointer? getFileAttributes(String filePath) { + if (!File(filePath).existsSync()) { + throw ArgumentError.value(filePath, 'filePath', 'File not present'); + } + + final lptstrFilename = TEXT(filePath); + final lpFileInformation = calloc(); + + try { + if (GetFileAttributesEx(lptstrFilename, 0, lpFileInformation) == 0) { + free(lpFileInformation); + + return null; + } + + return lpFileInformation; + } finally {} + } + + static DateTime? fileTimeToDartDateTime(FILETIME? fileTime) { + if (fileTime == null) return null; + + final high = fileTime.dwHighDateTime; + final low = fileTime.dwLowDateTime; + + final fileTime64 = (high << 32) + low; + + final unixTimeMs = ((fileTime64 ~/ 10000) - 11644473600000); + + return DateTime.fromMillisecondsSinceEpoch(unixTimeMs); + } +} diff --git a/packages/package_info_plus/package_info_plus/lib/src/package_info_plus_linux.dart b/packages/package_info_plus/package_info_plus/lib/src/package_info_plus_linux.dart index 581d648a8d..f04fda6ed7 100644 --- a/packages/package_info_plus/package_info_plus/lib/src/package_info_plus_linux.dart +++ b/packages/package_info_plus/package_info_plus/lib/src/package_info_plus_linux.dart @@ -23,6 +23,7 @@ class PackageInfoPlusLinuxPlugin extends PackageInfoPlatform { buildNumber: versionJson['build_number'] ?? '', packageName: versionJson['package_name'] ?? '', buildSignature: '', + installTime: versionJson['install_time'], ); } @@ -32,9 +33,35 @@ class PackageInfoPlusLinuxPlugin extends PackageInfoPlatform { final appPath = path.dirname(exePath); final assetPath = path.join(appPath, 'data', 'flutter_assets'); final versionPath = path.join(assetPath, 'version.json'); - return jsonDecode(await File(versionPath).readAsString()); + + final installTime = await _getInstallTime(exePath); + + return { + ...jsonDecode(await File(versionPath).readAsString()), + 'install_time': installTime, + }; } catch (_) { return {}; } } + + Future _getInstallTime(String exePath) async { + try { + final statResult = await Process.run( + 'stat', + ['-c', '%W', exePath], + stdoutEncoding: utf8, + ); + + if (statResult.exitCode == 0 && int.tryParse(statResult.stdout) != null) { + final creationTimeSeconds = int.parse(statResult.stdout) * 1000; + + return DateTime.fromMillisecondsSinceEpoch(creationTimeSeconds); + } + + return await File(exePath).lastModified(); + } catch (_) { + return null; + } + } } diff --git a/packages/package_info_plus/package_info_plus/lib/src/package_info_plus_windows.dart b/packages/package_info_plus/package_info_plus/lib/src/package_info_plus_windows.dart index 7316d752c5..acab12ed71 100644 --- a/packages/package_info_plus/package_info_plus/lib/src/package_info_plus_windows.dart +++ b/packages/package_info_plus/package_info_plus/lib/src/package_info_plus_windows.dart @@ -1,11 +1,12 @@ /// The Windows implementation of `package_info_plus`. -library package_info_plus_windows; +library; import 'dart:io'; import 'package:package_info_plus_platform_interface/package_info_data.dart'; import 'package:package_info_plus_platform_interface/package_info_platform_interface.dart'; +import 'file_attribute.dart'; import 'file_version_info.dart'; /// The Windows implementation of [PackageInfoPlatform]. @@ -27,6 +28,7 @@ class PackageInfoPlusWindowsPlugin extends PackageInfoPlatform { } final info = FileVersionInfo(resolvedExecutable); + final attributes = FileAttributes(resolvedExecutable); final versions = info.productVersion.split('+'); final data = PackageInfoData( appName: info.productName, @@ -34,6 +36,7 @@ class PackageInfoPlusWindowsPlugin extends PackageInfoPlatform { version: versions.getOrNull(0) ?? '', buildNumber: versions.getOrNull(1) ?? '', buildSignature: '', + installTime: attributes.creationTime ?? attributes.lastWriteTime, ); info.dispose(); return Future.value(data); diff --git a/packages/package_info_plus/package_info_plus/macos/package_info_plus/Sources/package_info_plus/FPPPackageInfoPlusPlugin.m b/packages/package_info_plus/package_info_plus/macos/package_info_plus/Sources/package_info_plus/FPPPackageInfoPlusPlugin.m index 57ec114469..46e45f934e 100644 --- a/packages/package_info_plus/package_info_plus/macos/package_info_plus/Sources/package_info_plus/FPPPackageInfoPlusPlugin.m +++ b/packages/package_info_plus/package_info_plus/macos/package_info_plus/Sources/package_info_plus/FPPPackageInfoPlusPlugin.m @@ -16,6 +16,11 @@ + (void)registerWithRegistrar:(NSObject *)registrar { - (void)handleMethodCall:(FlutterMethodCall *)call result:(FlutterResult)result { if ([call.method isEqualToString:@"getAll"]) { + 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"] @@ -29,7 +34,8 @@ - (void)handleMethodCall:(FlutterMethodCall *)call @"buildNumber" : [[NSBundle mainBundle] objectForInfoDictionaryKey:@"CFBundleVersion"] ?: [NSNull null], - @"installerStore" : [NSNull null] + @"installerStore" : [NSNull null], + @"installTime" : installTimeMillis ? [installTimeMillis stringValue] : [NSNull null] }); } else { result(FlutterMethodNotImplemented); diff --git a/packages/package_info_plus/package_info_plus/test/package_info_plus_windows_test.dart b/packages/package_info_plus/package_info_plus/test/package_info_plus_windows_test.dart index 1ddc60ef9a..4b76909c7a 100644 --- a/packages/package_info_plus/package_info_plus/test/package_info_plus_windows_test.dart +++ b/packages/package_info_plus/package_info_plus/test/package_info_plus_windows_test.dart @@ -1,9 +1,10 @@ @TestOn('windows') -library package_info_plus_windows_test; +library; -import 'dart:io' show Platform; +import 'dart:io' show File, Platform; import 'package:flutter_test/flutter_test.dart'; +import 'package:package_info_plus/src/file_attribute.dart'; import 'package:package_info_plus/src/file_version_info.dart'; import 'package:package_info_plus/src/package_info_plus_windows.dart'; import 'package:package_info_plus_platform_interface/package_info_platform_interface.dart'; @@ -31,6 +32,32 @@ void main() { kernelVersion.dispose(); }); + test('File creation and modification time', () async { + final DateTime now = DateTime.now(); + final testFile = await File('./test.txt').create(); + + final fileAttributes = FileAttributes(testFile.path); + + expect( + fileAttributes.creationTime, + isA().having( + (d) => d.difference(now).inSeconds, + 'Was just created', + lessThanOrEqualTo(1), + ), + ); + expect( + fileAttributes.lastWriteTime, + isA().having( + (d) => d.difference(now).inSeconds, + 'Was just modified', + lessThanOrEqualTo(1), + ), + ); + + await testFile.delete(); + }); + test('File version info for missing file', () { const missingFile = 'C:\\macos\\system128\\colonel.dll'; diff --git a/packages/package_info_plus/package_info_plus_platform_interface/lib/package_info_data.dart b/packages/package_info_plus/package_info_plus_platform_interface/lib/package_info_data.dart index bd630587ed..8ac2712a25 100644 --- a/packages/package_info_plus/package_info_plus_platform_interface/lib/package_info_data.dart +++ b/packages/package_info_plus/package_info_plus_platform_interface/lib/package_info_data.dart @@ -31,6 +31,13 @@ class PackageInfoData { /// The installer store. Indicates through which store this application was installed. final String? installerStore; - /// The time when the application was installed. The creation date of documents directory on iOS, `firstInstallTime` on Android, null otherwise. + /// 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; } From 564b59982666023b06e9765817772b215e6df0ba Mon Sep 17 00:00:00 2001 From: Andrew Bekhiet <40571928+Andrew-Bekhiet@users.noreply.github.com> Date: Sun, 19 Jan 2025 20:21:03 +0200 Subject: [PATCH 4/5] fix(package_info_plus): add missing call to free filename pointer refactor(package_info_plus): simplify get file attributes logic --- .../lib/src/file_attribute.dart | 44 +++++++++++-------- 1 file changed, 26 insertions(+), 18 deletions(-) diff --git a/packages/package_info_plus/package_info_plus/lib/src/file_attribute.dart b/packages/package_info_plus/package_info_plus/lib/src/file_attribute.dart index 0dc87992d8..d4ce98c49d 100644 --- a/packages/package_info_plus/package_info_plus/lib/src/file_attribute.dart +++ b/packages/package_info_plus/package_info_plus/lib/src/file_attribute.dart @@ -28,20 +28,17 @@ class FileAttributes { late final DateTime? lastWriteTime; FileAttributes(this.filePath) { - final attributesPtr = getFileAttributes(filePath); + final (:creationTime, :lastWriteTime) = + getFileCreationAndLastWriteTime(filePath); - if (attributesPtr != null) { - creationTime = fileTimeToDartDateTime(attributesPtr.ref.ftCreationTime); - lastWriteTime = fileTimeToDartDateTime(attributesPtr.ref.ftLastWriteTime); - - free(attributesPtr); - } else { - creationTime = null; - lastWriteTime = null; - } + this.creationTime = creationTime; + this.lastWriteTime = lastWriteTime; } - static Pointer? getFileAttributes(String filePath) { + static ({ + DateTime? creationTime, + DateTime? lastWriteTime, + }) getFileCreationAndLastWriteTime(String filePath) { if (!File(filePath).existsSync()) { throw ArgumentError.value(filePath, 'filePath', 'File not present'); } @@ -51,13 +48,23 @@ class FileAttributes { try { if (GetFileAttributesEx(lptstrFilename, 0, lpFileInformation) == 0) { - free(lpFileInformation); - - return null; + throw WindowsException(HRESULT_FROM_WIN32(GetLastError())); } - return lpFileInformation; - } finally {} + final FILEATTRIBUTEDATA fileInformation = lpFileInformation.ref; + + return ( + creationTime: fileTimeToDartDateTime( + fileInformation.ftCreationTime, + ), + lastWriteTime: fileTimeToDartDateTime( + fileInformation.ftLastWriteTime, + ), + ); + } finally { + free(lptstrFilename); + free(lpFileInformation); + } } static DateTime? fileTimeToDartDateTime(FILETIME? fileTime) { @@ -68,8 +75,9 @@ class FileAttributes { final fileTime64 = (high << 32) + low; - final unixTimeMs = ((fileTime64 ~/ 10000) - 11644473600000); + final windowsTimeMillis = fileTime64 ~/ 10000; + final unixTimeMillis = windowsTimeMillis - 11644473600000; - return DateTime.fromMillisecondsSinceEpoch(unixTimeMs); + return DateTime.fromMillisecondsSinceEpoch(unixTimeMillis); } } From 0d01813126dcc37a110d7b01d3d46c00c6e52e40 Mon Sep 17 00:00:00 2001 From: Andrew Bekhiet <40571928+Andrew-Bekhiet@users.noreply.github.com> Date: Sun, 19 Jan 2025 20:22:54 +0200 Subject: [PATCH 5/5] refactor(package_info_plus): decouple install time from version json --- .../lib/src/package_info_plus_linux.dart | 18 ++++++++---------- 1 file changed, 8 insertions(+), 10 deletions(-) diff --git a/packages/package_info_plus/package_info_plus/lib/src/package_info_plus_linux.dart b/packages/package_info_plus/package_info_plus/lib/src/package_info_plus_linux.dart index f04fda6ed7..9ba8e066fe 100644 --- a/packages/package_info_plus/package_info_plus/lib/src/package_info_plus_linux.dart +++ b/packages/package_info_plus/package_info_plus/lib/src/package_info_plus_linux.dart @@ -16,30 +16,28 @@ class PackageInfoPlusLinuxPlugin extends PackageInfoPlatform { /// appName, packageName, version, buildNumber @override Future getAll({String? baseUrl}) async { - final versionJson = await _getVersionJson(); + final exePath = await File('/proc/self/exe').resolveSymbolicLinks(); + + final versionJson = await _getVersionJson(exePath); + final installTime = await _getInstallTime(exePath); + return PackageInfoData( appName: versionJson['app_name'] ?? '', version: versionJson['version'] ?? '', buildNumber: versionJson['build_number'] ?? '', packageName: versionJson['package_name'] ?? '', buildSignature: '', - installTime: versionJson['install_time'], + installTime: installTime, ); } - Future> _getVersionJson() async { + Future> _getVersionJson(String exePath) async { try { - final exePath = await File('/proc/self/exe').resolveSymbolicLinks(); final appPath = path.dirname(exePath); final assetPath = path.join(appPath, 'data', 'flutter_assets'); final versionPath = path.join(assetPath, 'version.json'); - final installTime = await _getInstallTime(exePath); - - return { - ...jsonDecode(await File(versionPath).readAsString()), - 'install_time': installTime, - }; + return jsonDecode(await File(versionPath).readAsString()); } catch (_) { return {}; }