Skip to content

Commit 0ea0402

Browse files
feat(package_info_plus): add install time (#3434)
1 parent ea72b63 commit 0ea0402

File tree

14 files changed

+298
-14
lines changed

14 files changed

+298
-14
lines changed

packages/package_info_plus/package_info_plus/android/src/main/kotlin/dev/fluttercommunity/plus/packageinfo/PackageInfoPlugin.kt

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ class PackageInfoPlugin : MethodCallHandler, FlutterPlugin {
3939
val buildSignature = getBuildSignature(packageManager)
4040

4141
val installerPackage = getInstallerPackageName()
42+
val installTimeMillis = getInstallTimeMillis()
4243

4344
val infoMap = HashMap<String, String>()
4445
infoMap.apply {
@@ -48,6 +49,7 @@ class PackageInfoPlugin : MethodCallHandler, FlutterPlugin {
4849
put("buildNumber", getLongVersionCode(info).toString())
4950
if (buildSignature != null) put("buildSignature", buildSignature)
5051
if (installerPackage != null) put("installerStore", installerPackage)
52+
if (installTimeMillis != null) put("installTime", installTimeMillis.toString())
5153
}.also { resultingMap ->
5254
result.success(resultingMap)
5355
}
@@ -74,6 +76,22 @@ class PackageInfoPlugin : MethodCallHandler, FlutterPlugin {
7476
}
7577
}
7678

79+
private fun getInstallTimeMillis(): Long? {
80+
return try {
81+
val packageManager = applicationContext!!.packageManager
82+
val packageName = applicationContext!!.packageName
83+
val packageInfo = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
84+
packageManager.getPackageInfo(packageName, PackageManager.PackageInfoFlags.of(0))
85+
} else {
86+
packageManager.getPackageInfo(packageName, 0)
87+
}
88+
89+
packageInfo.firstInstallTime
90+
} catch (e: PackageManager.NameNotFoundException) {
91+
null
92+
}
93+
}
94+
7795
@Suppress("deprecation")
7896
private fun getLongVersionCode(info: PackageInfo): Long {
7997
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {

packages/package_info_plus/package_info_plus/example/integration_test/package_info_plus_test.dart

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@ const android14SDK = 34;
1616
void main() {
1717
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
1818

19+
final testStartTime = DateTime.now();
20+
1921
testWidgets('fromPlatform', (WidgetTester tester) async {
2022
final info = await PackageInfo.fromPlatform();
2123
// These tests are based on the example app. The tests should be updated if any related info changes.
@@ -26,6 +28,7 @@ void main() {
2628
expect(info.packageName, 'package_info_plus_example');
2729
expect(info.version, '1.2.3');
2830
expect(info.installerStore, null);
31+
expect(info.installTime, null);
2932
} else {
3033
if (Platform.isAndroid) {
3134
final androidVersionInfo = await DeviceInfoPlugin().androidInfo;
@@ -41,33 +44,73 @@ void main() {
4144
} else {
4245
expect(info.installerStore, null);
4346
}
47+
expect(
48+
info.installTime,
49+
isA<DateTime>().having(
50+
(d) => d.difference(DateTime.now()).inMinutes,
51+
'Was just installed',
52+
lessThanOrEqualTo(1),
53+
),
54+
);
4455
} else if (Platform.isIOS) {
4556
expect(info.appName, 'Package Info Plus Example');
4657
expect(info.buildNumber, '4');
4758
expect(info.buildSignature, isEmpty);
4859
expect(info.packageName, 'io.flutter.plugins.packageInfoExample');
4960
expect(info.version, '1.2.3');
5061
expect(info.installerStore, 'com.apple.simulator');
62+
expect(
63+
info.installTime,
64+
isA<DateTime>().having(
65+
(d) => d.difference(DateTime.now()).inMinutes,
66+
'Was just installed',
67+
lessThanOrEqualTo(1),
68+
),
69+
);
5170
} else if (Platform.isMacOS) {
5271
expect(info.appName, 'Package Info Plus Example');
5372
expect(info.buildNumber, '4');
5473
expect(info.buildSignature, isEmpty);
5574
expect(info.packageName, 'io.flutter.plugins.packageInfoExample');
5675
expect(info.version, '1.2.3');
5776
expect(info.installerStore, null);
77+
expect(
78+
info.installTime,
79+
isA<DateTime>().having(
80+
(d) => d.difference(DateTime.now()).inMinutes,
81+
'Was just installed',
82+
lessThanOrEqualTo(1),
83+
),
84+
);
5885
} else if (Platform.isLinux) {
5986
expect(info.appName, 'package_info_plus_example');
6087
expect(info.buildNumber, '4');
6188
expect(info.buildSignature, isEmpty);
6289
expect(info.packageName, 'package_info_plus_example');
6390
expect(info.version, '1.2.3');
91+
expect(
92+
info.installTime,
93+
isA<DateTime>().having(
94+
(d) => d.difference(DateTime.now()).inMinutes,
95+
'Was just installed',
96+
lessThanOrEqualTo(1),
97+
),
98+
);
6499
} else if (Platform.isWindows) {
65100
expect(info.appName, 'example');
66101
expect(info.buildNumber, '4');
67102
expect(info.buildSignature, isEmpty);
68103
expect(info.packageName, 'example');
69104
expect(info.version, '1.2.3');
70105
expect(info.installerStore, null);
106+
expect(
107+
info.installTime,
108+
isA<DateTime>().having(
109+
(d) => d.difference(DateTime.now()).inMinutes,
110+
'Was just installed',
111+
lessThanOrEqualTo(1),
112+
),
113+
);
71114
} else {
72115
throw (UnsupportedError('platform not supported'));
73116
}
@@ -83,7 +126,16 @@ void main() {
83126
expect(find.text('4'), findsOneWidget);
84127
expect(find.text('Not set'), findsOneWidget);
85128
expect(find.text('not available'), findsOneWidget);
129+
expect(find.text('Install time not available'), findsOneWidget);
86130
} else {
131+
final expectedInstallTimeIso = testStartTime.toIso8601String();
132+
final installTimeRegex = RegExp(
133+
expectedInstallTimeIso.replaceAll(
134+
RegExp(r'\d:\d\d\..+$'),
135+
r'.+$',
136+
),
137+
);
138+
87139
if (Platform.isAndroid) {
88140
final androidVersionInfo = await DeviceInfoPlugin().androidInfo;
89141

@@ -101,6 +153,7 @@ void main() {
101153
} else {
102154
expect(find.text('not available'), findsOneWidget);
103155
}
156+
expect(find.textContaining(installTimeRegex), findsOneWidget);
104157
} else if (Platform.isIOS) {
105158
expect(find.text('Package Info Plus Example'), findsOneWidget);
106159
expect(find.text('4'), findsOneWidget);
@@ -109,6 +162,7 @@ void main() {
109162
expect(find.text('1.2.3'), findsOneWidget);
110163
expect(find.text('Not set'), findsOneWidget);
111164
expect(find.text('com.apple.simulator'), findsOneWidget);
165+
expect(find.textContaining(installTimeRegex), findsOneWidget);
112166
} else if (Platform.isMacOS) {
113167
expect(find.text('Package Info Plus Example'), findsOneWidget);
114168
expect(find.text('4'), findsOneWidget);
@@ -117,17 +171,20 @@ void main() {
117171
expect(find.text('1.2.3'), findsOneWidget);
118172
expect(find.text('Not set'), findsOneWidget);
119173
expect(find.text('not available'), findsOneWidget);
174+
expect(find.textContaining(installTimeRegex), findsOneWidget);
120175
} else if (Platform.isLinux) {
121176
expect(find.text('package_info_plus_example'), findsNWidgets(2));
122177
expect(find.text('1.2.3'), findsOneWidget);
123178
expect(find.text('4'), findsOneWidget);
124179
expect(find.text('Not set'), findsOneWidget);
180+
expect(find.textContaining(installTimeRegex), findsOneWidget);
125181
} else if (Platform.isWindows) {
126182
expect(find.text('example'), findsNWidgets(2));
127183
expect(find.text('1.2.3'), findsOneWidget);
128184
expect(find.text('4'), findsOneWidget);
129185
expect(find.text('Not set'), findsOneWidget);
130186
expect(find.text('not available'), findsOneWidget);
187+
expect(find.textContaining(installTimeRegex), findsOneWidget);
131188
} else {
132189
throw (UnsupportedError('platform not supported'));
133190
}

packages/package_info_plus/package_info_plus/example/lib/main.dart

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,11 @@ class _MyHomePageState extends State<MyHomePage> {
8484
'Installer store',
8585
_packageInfo.installerStore ?? 'not available',
8686
),
87+
_infoTile(
88+
'Install time',
89+
_packageInfo.installTime?.toIso8601String() ??
90+
'Install time not available',
91+
),
8792
],
8893
),
8994
);

packages/package_info_plus/package_info_plus/example/pubspec.yaml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
11
name: package_info_plus_example
22
description: Demonstrates how to use the package_info_plus plugin.
33
version: 1.2.3+4
4-
publish_to: 'none'
4+
publish_to: "none"
55

66
environment:
7-
sdk: '>=2.18.0 <4.0.0'
7+
sdk: ">=2.18.0 <4.0.0"
88

99
dependencies:
1010
clock: ^1.1.1

packages/package_info_plus/package_info_plus/ios/package_info_plus/Sources/package_info_plus/FPPPackageInfoPlusPlugin.m

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,12 @@ - (void)handleMethodCall:(FlutterMethodCall *)call
2626
? @"com.apple.testflight"
2727
: @"com.apple";
2828

29+
NSURL* urlToDocumentsFolder = [[[NSFileManager defaultManager] URLsForDirectory:NSDocumentDirectory inDomains:NSUserDomainMask] lastObject];
30+
__autoreleasing NSError *error;
31+
NSDate *installDate = [[[NSFileManager defaultManager] attributesOfItemAtPath:urlToDocumentsFolder.path error:&error] objectForKey:NSFileCreationDate];
32+
NSNumber *installTimeMillis = installDate ? @((long long)([installDate timeIntervalSince1970] * 1000)) : [NSNull null];
33+
34+
2935
result(@{
3036
@"appName" : [[NSBundle mainBundle]
3137
objectForInfoDictionaryKey:@"CFBundleDisplayName"]
@@ -39,8 +45,10 @@ - (void)handleMethodCall:(FlutterMethodCall *)call
3945
@"buildNumber" : [[NSBundle mainBundle]
4046
objectForInfoDictionaryKey:@"CFBundleVersion"]
4147
?: [NSNull null],
42-
@"installerStore" : installerStore
48+
@"installerStore" : installerStore,
49+
@"installTime" : installTimeMillis ? [installTimeMillis stringValue] : [NSNull null]
4350
});
51+
4452
} else {
4553
result(FlutterMethodNotImplemented);
4654
}

packages/package_info_plus/package_info_plus/lib/package_info_plus.dart

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ class PackageInfo {
2727
required this.buildNumber,
2828
this.buildSignature = '',
2929
this.installerStore,
30+
this.installTime,
3031
});
3132

3233
static PackageInfo? _fromPlatform;
@@ -88,6 +89,7 @@ class PackageInfo {
8889
buildNumber: platformData.buildNumber,
8990
buildSignature: platformData.buildSignature,
9091
installerStore: platformData.installerStore,
92+
installTime: platformData.installTime,
9193
);
9294
return _fromPlatform!;
9395
}
@@ -147,6 +149,16 @@ class PackageInfo {
147149
/// The installer store. Indicates through which store this application was installed.
148150
final String? installerStore;
149151

152+
/// The time when the application was installed.
153+
///
154+
/// - On Android, returns `PackageManager.firstInstallTime`
155+
/// - On iOS and macOS, return the creation date of the app default `NSDocumentDirectory`
156+
/// - On Windows and Linux, returns the creation date of the app executable.
157+
/// If the creation date is not available, returns the last modified date of the app executable.
158+
/// If the last modified date is not available, returns `null`.
159+
/// - On web, returns `null`.
160+
final DateTime? installTime;
161+
150162
/// Initializes the application metadata with mock values for testing.
151163
///
152164
/// If the singleton instance has been initialized already, it is overwritten.
@@ -158,6 +170,7 @@ class PackageInfo {
158170
required String buildNumber,
159171
required String buildSignature,
160172
String? installerStore,
173+
DateTime? installTime,
161174
}) {
162175
_fromPlatform = PackageInfo(
163176
appName: appName,
@@ -166,6 +179,7 @@ class PackageInfo {
166179
buildNumber: buildNumber,
167180
buildSignature: buildSignature,
168181
installerStore: installerStore,
182+
installTime: installTime,
169183
);
170184
}
171185

@@ -180,7 +194,8 @@ class PackageInfo {
180194
version == other.version &&
181195
buildNumber == other.buildNumber &&
182196
buildSignature == other.buildSignature &&
183-
installerStore == other.installerStore;
197+
installerStore == other.installerStore &&
198+
installTime == other.installTime;
184199

185200
/// Overwrite hashCode for value equality
186201
@override
@@ -190,11 +205,12 @@ class PackageInfo {
190205
version.hashCode ^
191206
buildNumber.hashCode ^
192207
buildSignature.hashCode ^
193-
installerStore.hashCode;
208+
installerStore.hashCode ^
209+
installTime.hashCode;
194210

195211
@override
196212
String toString() {
197-
return 'PackageInfo(appName: $appName, buildNumber: $buildNumber, packageName: $packageName, version: $version, buildSignature: $buildSignature, installerStore: $installerStore)';
213+
return 'PackageInfo(appName: $appName, buildNumber: $buildNumber, packageName: $packageName, version: $version, buildSignature: $buildSignature, installerStore: $installerStore, installTime: $installTime)';
198214
}
199215

200216
Map<String, dynamic> _toMap() {
@@ -205,6 +221,7 @@ class PackageInfo {
205221
'version': version,
206222
if (buildSignature.isNotEmpty) 'buildSignature': buildSignature,
207223
if (installerStore?.isNotEmpty ?? false) 'installerStore': installerStore,
224+
if (installTime != null) 'installTime': installTime
208225
};
209226
}
210227

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
import 'dart:ffi';
2+
import 'dart:io';
3+
4+
import 'package:ffi/ffi.dart';
5+
import 'package:win32/win32.dart';
6+
7+
base class FILEATTRIBUTEDATA extends Struct {
8+
@DWORD()
9+
external int dwFileAttributes;
10+
11+
external FILETIME ftCreationTime;
12+
13+
external FILETIME ftLastAccessTime;
14+
15+
external FILETIME ftLastWriteTime;
16+
17+
@DWORD()
18+
external int nFileSizeHigh;
19+
20+
@DWORD()
21+
external int nFileSizeLow;
22+
}
23+
24+
class FileAttributes {
25+
final String filePath;
26+
27+
late final DateTime? creationTime;
28+
late final DateTime? lastWriteTime;
29+
30+
FileAttributes(this.filePath) {
31+
final (:creationTime, :lastWriteTime) =
32+
getFileCreationAndLastWriteTime(filePath);
33+
34+
this.creationTime = creationTime;
35+
this.lastWriteTime = lastWriteTime;
36+
}
37+
38+
static ({
39+
DateTime? creationTime,
40+
DateTime? lastWriteTime,
41+
}) getFileCreationAndLastWriteTime(String filePath) {
42+
if (!File(filePath).existsSync()) {
43+
throw ArgumentError.value(filePath, 'filePath', 'File not present');
44+
}
45+
46+
final lptstrFilename = TEXT(filePath);
47+
final lpFileInformation = calloc<FILEATTRIBUTEDATA>();
48+
49+
try {
50+
if (GetFileAttributesEx(lptstrFilename, 0, lpFileInformation) == 0) {
51+
throw WindowsException(HRESULT_FROM_WIN32(GetLastError()));
52+
}
53+
54+
final FILEATTRIBUTEDATA fileInformation = lpFileInformation.ref;
55+
56+
return (
57+
creationTime: fileTimeToDartDateTime(
58+
fileInformation.ftCreationTime,
59+
),
60+
lastWriteTime: fileTimeToDartDateTime(
61+
fileInformation.ftLastWriteTime,
62+
),
63+
);
64+
} finally {
65+
free(lptstrFilename);
66+
free(lpFileInformation);
67+
}
68+
}
69+
70+
static DateTime? fileTimeToDartDateTime(FILETIME? fileTime) {
71+
if (fileTime == null) return null;
72+
73+
final high = fileTime.dwHighDateTime;
74+
final low = fileTime.dwLowDateTime;
75+
76+
final fileTime64 = (high << 32) + low;
77+
78+
final windowsTimeMillis = fileTime64 ~/ 10000;
79+
final unixTimeMillis = windowsTimeMillis - 11644473600000;
80+
81+
return DateTime.fromMillisecondsSinceEpoch(unixTimeMillis);
82+
}
83+
}

0 commit comments

Comments
 (0)