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..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 @@ -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,28 @@ void main() { expect(info.packageName, 'io.flutter.plugins.packageInfoExample'); expect(info.version, '1.2.3'); expect(info.installerStore, 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, + 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'); @@ -68,6 +103,14 @@ void main() { expect(info.packageName, 'example'); expect(info.version, '1.2.3'); expect(info.installerStore, null); + expect( + info.installTime, + isA().having( + (d) => d.difference(DateTime.now()).inMinutes, + 'Was just installed', + lessThanOrEqualTo(1), + ), + ); } else { throw (UnsupportedError('platform not supported')); } @@ -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; @@ -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); @@ -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); @@ -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')); } 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 f3bc6ae5cf..db609f60a9 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,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. @@ -158,6 +170,7 @@ class PackageInfo { required String buildNumber, required String buildSignature, String? installerStore, + DateTime? installTime, }) { _fromPlatform = PackageInfo( appName: appName, @@ -166,6 +179,7 @@ class PackageInfo { buildNumber: buildNumber, buildSignature: buildSignature, installerStore: installerStore, + installTime: installTime, ); } @@ -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 @@ -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 _toMap() { @@ -205,6 +221,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/lib/src/file_attribute.dart b/packages/package_info_plus/package_info_plus/lib/src/file_attribute.dart new file mode 100644 index 0000000000..d4ce98c49d --- /dev/null +++ b/packages/package_info_plus/package_info_plus/lib/src/file_attribute.dart @@ -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(); + + 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); + } +} 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..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,25 +16,50 @@ 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: 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'); + return jsonDecode(await File(versionPath).readAsString()); } 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/test/package_info_test.dart b/packages/package_info_plus/package_info_plus/test/package_info_test.dart index af7bb1040e..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 @@ -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,6 +96,7 @@ void main() { version: '1.0', buildSignature: '', installerStore: null, + installTime: now, ); expect(info1, info2); }); @@ -101,6 +109,7 @@ void main() { version: '1.0', buildSignature: '', installerStore: null, + installTime: now, ); final info2 = PackageInfo( appName: 'package_info_example', @@ -109,6 +118,7 @@ void main() { version: '1.0', buildSignature: '', installerStore: null, + installTime: now, ); expect(info1.hashCode, info2.hashCode); }); @@ -121,10 +131,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 +147,7 @@ void main() { buildNumber: '2', buildSignature: '', installerStore: null, + installTime: now, ); final info1 = await PackageInfo.fromPlatform(); expect(info1.data, { @@ -143,7 +155,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 +166,7 @@ void main() { buildNumber: '2', buildSignature: 'deadbeef', installerStore: 'testflight', + installTime: nextWeek, ); final info2 = await PackageInfo.fromPlatform(); expect(info2.data, { @@ -160,6 +176,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..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 @@ -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,14 @@ class PackageInfoData { /// 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; }