Compare commits

..

1 Commits

Author SHA1 Message Date
vishnukvmd
834bee3f46 Add Info
Co-authored-by: GitHub Copilot <noreply@github.com>
2025-09-11 18:33:45 +05:30
96 changed files with 2923 additions and 2628 deletions

3
.gitmodules vendored
View File

@@ -9,6 +9,3 @@
[submodule "auth/assets/simple-icons"]
path = mobile/apps/auth/assets/simple-icons
url = https://github.com/simple-icons/simple-icons.git
[submodule "mobile/thirdparty/flutter"]
path = mobile/thirdparty/flutter
url = https://github.com/flutter/flutter.git

View File

@@ -349,5 +349,52 @@
"mastodon": "Mastodon",
"matrix": "Matrix",
"discord": "Discord",
"reddit": "Reddit"
"reddit": "Reddit",
"information": "Information",
"saveInformation": "Save information",
"informationDescription": "Save important information that can be shared and passed down to loved ones.",
"personalNote": "Personal note",
"personalNoteDescription": "Save important notes or thoughts",
"physicalRecords": "Physical records",
"physicalRecordsDescription": "Save the real-world locations of important items",
"accountCredentials": "Account credentials",
"accountCredentialsDescription": "Securely store login details for important accounts",
"emergencyContact": "Emergency contact",
"emergencyContactDescription": "Save details of people to contact in emergencies",
"noteName": "Title",
"noteNameHint": "Give your note a meaningful title",
"noteContent": "Content",
"noteContentHint": "Write down important thoughts, instructions, or memories you want to preserve",
"recordName": "Record name",
"recordNameHint": "Name of the real-world item",
"recordLocation": "Location",
"recordLocationHint": "Where can this item be found? (e.g., 'Safety deposit box at First Bank, Box #123')",
"recordNotes": "Notes",
"recordNotesHint": "Any additional details about accessing or understanding this record",
"credentialName": "Account name",
"credentialNameHint": "Name of the service or account",
"username": "Username",
"usernameHint": "Login username or email address",
"password": "Password",
"passwordHint": "Account password",
"credentialNotes": "Additional notes",
"credentialNotesHint": "Recovery methods, security questions, or other important details",
"contactName": "Contact name",
"contactNameHint": "Full name of the emergency contact",
"contactDetails": "Contact details",
"contactDetailsHint": "Phone number, email, or other contact information",
"contactNotes": "Message for contact",
"contactNotesHint": "Important information to share with this person when they are contacted",
"saveRecord": "Save",
"recordSavedSuccessfully": "Record saved successfully",
"failedToSaveRecord": "Failed to save record",
"pleaseEnterNoteName": "Please enter a title",
"pleaseEnterNoteContent": "Please enter content",
"pleaseEnterRecordName": "Please enter a record name",
"pleaseEnterLocation": "Please enter a location",
"pleaseEnterAccountName": "Please enter an account name",
"pleaseEnterUsername": "Please enter a username",
"pleaseEnterPassword": "Please enter a password",
"pleaseEnterContactName": "Please enter a contact name",
"pleaseEnterContactDetails": "Please enter contact details"
}

View File

@@ -1017,6 +1017,288 @@ abstract class AppLocalizations {
/// In en, this message translates to:
/// **'Reddit'**
String get reddit;
/// No description provided for @information.
///
/// In en, this message translates to:
/// **'Information'**
String get information;
/// No description provided for @saveInformation.
///
/// In en, this message translates to:
/// **'Save information'**
String get saveInformation;
/// No description provided for @informationDescription.
///
/// In en, this message translates to:
/// **'Save important information that can be shared and passed down to loved ones.'**
String get informationDescription;
/// No description provided for @personalNote.
///
/// In en, this message translates to:
/// **'Personal note'**
String get personalNote;
/// No description provided for @personalNoteDescription.
///
/// In en, this message translates to:
/// **'Save important notes or thoughts'**
String get personalNoteDescription;
/// No description provided for @physicalRecords.
///
/// In en, this message translates to:
/// **'Physical records'**
String get physicalRecords;
/// No description provided for @physicalRecordsDescription.
///
/// In en, this message translates to:
/// **'Save the real-world locations of important items'**
String get physicalRecordsDescription;
/// No description provided for @accountCredentials.
///
/// In en, this message translates to:
/// **'Account credentials'**
String get accountCredentials;
/// No description provided for @accountCredentialsDescription.
///
/// In en, this message translates to:
/// **'Securely store login details for important accounts'**
String get accountCredentialsDescription;
/// No description provided for @emergencyContact.
///
/// In en, this message translates to:
/// **'Emergency contact'**
String get emergencyContact;
/// No description provided for @emergencyContactDescription.
///
/// In en, this message translates to:
/// **'Save details of people to contact in emergencies'**
String get emergencyContactDescription;
/// No description provided for @noteName.
///
/// In en, this message translates to:
/// **'Title'**
String get noteName;
/// No description provided for @noteNameHint.
///
/// In en, this message translates to:
/// **'Give your note a meaningful title'**
String get noteNameHint;
/// No description provided for @noteContent.
///
/// In en, this message translates to:
/// **'Content'**
String get noteContent;
/// No description provided for @noteContentHint.
///
/// In en, this message translates to:
/// **'Write down important thoughts, instructions, or memories you want to preserve'**
String get noteContentHint;
/// No description provided for @recordName.
///
/// In en, this message translates to:
/// **'Record name'**
String get recordName;
/// No description provided for @recordNameHint.
///
/// In en, this message translates to:
/// **'Name of the real-world item'**
String get recordNameHint;
/// No description provided for @recordLocation.
///
/// In en, this message translates to:
/// **'Location'**
String get recordLocation;
/// No description provided for @recordLocationHint.
///
/// In en, this message translates to:
/// **'Where can this item be found? (e.g., \'Safety deposit box at First Bank, Box #123\')'**
String get recordLocationHint;
/// No description provided for @recordNotes.
///
/// In en, this message translates to:
/// **'Notes'**
String get recordNotes;
/// No description provided for @recordNotesHint.
///
/// In en, this message translates to:
/// **'Any additional details about accessing or understanding this record'**
String get recordNotesHint;
/// No description provided for @credentialName.
///
/// In en, this message translates to:
/// **'Account name'**
String get credentialName;
/// No description provided for @credentialNameHint.
///
/// In en, this message translates to:
/// **'Name of the service or account'**
String get credentialNameHint;
/// No description provided for @username.
///
/// In en, this message translates to:
/// **'Username'**
String get username;
/// No description provided for @usernameHint.
///
/// In en, this message translates to:
/// **'Login username or email address'**
String get usernameHint;
/// No description provided for @password.
///
/// In en, this message translates to:
/// **'Password'**
String get password;
/// No description provided for @passwordHint.
///
/// In en, this message translates to:
/// **'Account password'**
String get passwordHint;
/// No description provided for @credentialNotes.
///
/// In en, this message translates to:
/// **'Additional notes'**
String get credentialNotes;
/// No description provided for @credentialNotesHint.
///
/// In en, this message translates to:
/// **'Recovery methods, security questions, or other important details'**
String get credentialNotesHint;
/// No description provided for @contactName.
///
/// In en, this message translates to:
/// **'Contact name'**
String get contactName;
/// No description provided for @contactNameHint.
///
/// In en, this message translates to:
/// **'Full name of the emergency contact'**
String get contactNameHint;
/// No description provided for @contactDetails.
///
/// In en, this message translates to:
/// **'Contact details'**
String get contactDetails;
/// No description provided for @contactDetailsHint.
///
/// In en, this message translates to:
/// **'Phone number, email, or other contact information'**
String get contactDetailsHint;
/// No description provided for @contactNotes.
///
/// In en, this message translates to:
/// **'Message for contact'**
String get contactNotes;
/// No description provided for @contactNotesHint.
///
/// In en, this message translates to:
/// **'Important information to share with this person when they are contacted'**
String get contactNotesHint;
/// No description provided for @saveRecord.
///
/// In en, this message translates to:
/// **'Save'**
String get saveRecord;
/// No description provided for @recordSavedSuccessfully.
///
/// In en, this message translates to:
/// **'Record saved successfully'**
String get recordSavedSuccessfully;
/// No description provided for @failedToSaveRecord.
///
/// In en, this message translates to:
/// **'Failed to save record'**
String get failedToSaveRecord;
/// No description provided for @pleaseEnterNoteName.
///
/// In en, this message translates to:
/// **'Please enter a title'**
String get pleaseEnterNoteName;
/// No description provided for @pleaseEnterNoteContent.
///
/// In en, this message translates to:
/// **'Please enter content'**
String get pleaseEnterNoteContent;
/// No description provided for @pleaseEnterRecordName.
///
/// In en, this message translates to:
/// **'Please enter a record name'**
String get pleaseEnterRecordName;
/// No description provided for @pleaseEnterLocation.
///
/// In en, this message translates to:
/// **'Please enter a location'**
String get pleaseEnterLocation;
/// No description provided for @pleaseEnterAccountName.
///
/// In en, this message translates to:
/// **'Please enter an account name'**
String get pleaseEnterAccountName;
/// No description provided for @pleaseEnterUsername.
///
/// In en, this message translates to:
/// **'Please enter a username'**
String get pleaseEnterUsername;
/// No description provided for @pleaseEnterPassword.
///
/// In en, this message translates to:
/// **'Please enter a password'**
String get pleaseEnterPassword;
/// No description provided for @pleaseEnterContactName.
///
/// In en, this message translates to:
/// **'Please enter a contact name'**
String get pleaseEnterContactName;
/// No description provided for @pleaseEnterContactDetails.
///
/// In en, this message translates to:
/// **'Please enter contact details'**
String get pleaseEnterContactDetails;
}
class _AppLocalizationsDelegate

View File

@@ -534,4 +534,155 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get reddit => 'Reddit';
@override
String get information => 'Information';
@override
String get saveInformation => 'Save information';
@override
String get informationDescription =>
'Save important information that can be shared and passed down to loved ones.';
@override
String get personalNote => 'Personal note';
@override
String get personalNoteDescription => 'Save important notes or thoughts';
@override
String get physicalRecords => 'Physical records';
@override
String get physicalRecordsDescription =>
'Save the real-world locations of important items';
@override
String get accountCredentials => 'Account credentials';
@override
String get accountCredentialsDescription =>
'Securely store login details for important accounts';
@override
String get emergencyContact => 'Emergency contact';
@override
String get emergencyContactDescription =>
'Save details of people to contact in emergencies';
@override
String get noteName => 'Title';
@override
String get noteNameHint => 'Give your note a meaningful title';
@override
String get noteContent => 'Content';
@override
String get noteContentHint =>
'Write down important thoughts, instructions, or memories you want to preserve';
@override
String get recordName => 'Record name';
@override
String get recordNameHint => 'Name of the real-world item';
@override
String get recordLocation => 'Location';
@override
String get recordLocationHint =>
'Where can this item be found? (e.g., \'Safety deposit box at First Bank, Box #123\')';
@override
String get recordNotes => 'Notes';
@override
String get recordNotesHint =>
'Any additional details about accessing or understanding this record';
@override
String get credentialName => 'Account name';
@override
String get credentialNameHint => 'Name of the service or account';
@override
String get username => 'Username';
@override
String get usernameHint => 'Login username or email address';
@override
String get password => 'Password';
@override
String get passwordHint => 'Account password';
@override
String get credentialNotes => 'Additional notes';
@override
String get credentialNotesHint =>
'Recovery methods, security questions, or other important details';
@override
String get contactName => 'Contact name';
@override
String get contactNameHint => 'Full name of the emergency contact';
@override
String get contactDetails => 'Contact details';
@override
String get contactDetailsHint =>
'Phone number, email, or other contact information';
@override
String get contactNotes => 'Message for contact';
@override
String get contactNotesHint =>
'Important information to share with this person when they are contacted';
@override
String get saveRecord => 'Save';
@override
String get recordSavedSuccessfully => 'Record saved successfully';
@override
String get failedToSaveRecord => 'Failed to save record';
@override
String get pleaseEnterNoteName => 'Please enter a title';
@override
String get pleaseEnterNoteContent => 'Please enter content';
@override
String get pleaseEnterRecordName => 'Please enter a record name';
@override
String get pleaseEnterLocation => 'Please enter a location';
@override
String get pleaseEnterAccountName => 'Please enter an account name';
@override
String get pleaseEnterUsername => 'Please enter a username';
@override
String get pleaseEnterPassword => 'Please enter a password';
@override
String get pleaseEnterContactName => 'Please enter a contact name';
@override
String get pleaseEnterContactDetails => 'Please enter contact details';
}

View File

@@ -0,0 +1,54 @@
enum FileType {
image,
video,
livePhoto,
other,
info, // New type for information files
}
int getInt(FileType fileType) {
switch (fileType) {
case FileType.image:
return 0;
case FileType.video:
return 1;
case FileType.livePhoto:
return 2;
case FileType.other:
return 3;
case FileType.info:
return 4;
}
}
FileType getFileType(int fileType) {
switch (fileType) {
case 0:
return FileType.image;
case 1:
return FileType.video;
case 2:
return FileType.livePhoto;
case 3:
return FileType.other;
case 4:
return FileType.info;
default:
return FileType.other;
}
}
String getHumanReadableString(FileType fileType) {
switch (fileType) {
case FileType.image:
return 'Images';
case FileType.video:
return 'Videos';
case FileType.livePhoto:
return 'Live Photos';
case FileType.other:
return 'Other Files';
case FileType.info:
return 'Information';
}
}

View File

@@ -0,0 +1,244 @@
import 'dart:convert';
// Enum for different information types
enum InfoType {
note,
physicalRecord,
accountCredential,
emergencyContact,
}
// Extension to convert enum to string and vice versa
extension InfoTypeExtension on InfoType {
String get value {
switch (this) {
case InfoType.note:
return 'note';
case InfoType.physicalRecord:
return 'physical-record';
case InfoType.accountCredential:
return 'account-credential';
case InfoType.emergencyContact:
return 'emergency-contact';
}
}
static InfoType fromString(String value) {
switch (value) {
case 'note':
return InfoType.note;
case 'physical-record':
return InfoType.physicalRecord;
case 'account-credential':
return InfoType.accountCredential;
case 'emergency-contact':
return InfoType.emergencyContact;
default:
throw ArgumentError('Unknown InfoType: $value');
}
}
}
// Base class for all information data
abstract class InfoData {
Map<String, dynamic> toJson();
static InfoData fromJson(InfoType type, Map<String, dynamic> json) {
switch (type) {
case InfoType.note:
return PersonalNoteData.fromJson(json);
case InfoType.physicalRecord:
return PhysicalRecordData.fromJson(json);
case InfoType.accountCredential:
return AccountCredentialData.fromJson(json);
case InfoType.emergencyContact:
return EmergencyContactData.fromJson(json);
}
}
}
// Personal Note Data Model
class PersonalNoteData extends InfoData {
final String title;
final String content;
PersonalNoteData({
required this.title,
required this.content,
});
factory PersonalNoteData.fromJson(Map<String, dynamic> json) {
return PersonalNoteData(
title: json['title'] ?? '',
content: json['content'] ?? '',
);
}
@override
Map<String, dynamic> toJson() {
return {
'title': title,
'content': content,
};
}
}
// Physical Record Data Model
class PhysicalRecordData extends InfoData {
final String name;
final String location;
final String? notes;
PhysicalRecordData({
required this.name,
required this.location,
this.notes,
});
factory PhysicalRecordData.fromJson(Map<String, dynamic> json) {
return PhysicalRecordData(
name: json['name'] ?? '',
location: json['location'] ?? '',
notes: json['notes'],
);
}
@override
Map<String, dynamic> toJson() {
return {
'name': name,
'location': location,
if (notes != null && notes!.isNotEmpty) 'notes': notes,
};
}
}
// Account Credential Data Model
class AccountCredentialData extends InfoData {
final String name;
final String username;
final String password;
final String? notes;
AccountCredentialData({
required this.name,
required this.username,
required this.password,
this.notes,
});
factory AccountCredentialData.fromJson(Map<String, dynamic> json) {
return AccountCredentialData(
name: json['name'] ?? '',
username: json['username'] ?? '',
password: json['password'] ?? '',
notes: json['notes'],
);
}
@override
Map<String, dynamic> toJson() {
return {
'name': name,
'username': username,
'password': password,
if (notes != null && notes!.isNotEmpty) 'notes': notes,
};
}
}
// Emergency Contact Data Model
class EmergencyContactData extends InfoData {
final String name;
final String contactDetails;
final String? notes;
EmergencyContactData({
required this.name,
required this.contactDetails,
this.notes,
});
factory EmergencyContactData.fromJson(Map<String, dynamic> json) {
return EmergencyContactData(
name: json['name'] ?? '',
contactDetails: json['contactDetails'] ?? '',
notes: json['notes'],
);
}
@override
Map<String, dynamic> toJson() {
return {
'name': name,
'contactDetails': contactDetails,
if (notes != null && notes!.isNotEmpty) 'notes': notes,
};
}
}
// Main Information Item wrapper
class InfoItem {
final InfoType type;
final InfoData data;
final DateTime createdAt;
final DateTime? updatedAt;
InfoItem({
required this.type,
required this.data,
required this.createdAt,
this.updatedAt,
});
factory InfoItem.fromJson(Map<String, dynamic> json) {
final type = InfoTypeExtension.fromString(json['type']);
final data = InfoData.fromJson(type, json['data']);
return InfoItem(
type: type,
data: data,
createdAt: DateTime.parse(json['createdAt']),
updatedAt:
json['updatedAt'] != null ? DateTime.parse(json['updatedAt']) : null,
);
}
Map<String, dynamic> toJson() {
return {
'type': type.value,
'data': data.toJson(),
'createdAt': createdAt.toIso8601String(),
if (updatedAt != null) 'updatedAt': updatedAt!.toIso8601String(),
};
}
String toJsonString() => jsonEncode(toJson());
static InfoItem fromJsonString(String jsonString) {
return InfoItem.fromJson(jsonDecode(jsonString));
}
// Create a copy with updated data
InfoItem copyWith({
InfoType? type,
InfoData? data,
DateTime? createdAt,
DateTime? updatedAt,
}) {
return InfoItem(
type: type ?? this.type,
data: data ?? this.data,
createdAt: createdAt ?? this.createdAt,
updatedAt: updatedAt ?? this.updatedAt,
);
}
// Update with new data and timestamp
InfoItem update(InfoData newData) {
return copyWith(
data: newData,
updatedAt: DateTime.now(),
);
}
}

View File

@@ -1,6 +1,7 @@
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:locker/models/file_type.dart';
import 'package:locker/services/files/download/file_url.dart';
import 'package:locker/services/files/sync/models/file_magic.dart';
import 'package:logging/logging.dart';
@@ -23,6 +24,7 @@ class EnteFile {
String? thumbnailDecryptionHeader;
String? metadataDecryptionHeader;
int? fileSize;
FileType? fileType;
String? mMdEncodedJson;
int mMdVersion = 0;

View File

@@ -17,6 +17,7 @@ const motionVideoIndexKey = "mvi";
const noThumbKey = "noThumb";
const dateTimeKey = 'dateTime';
const offsetTimeKey = 'offsetTime';
const infoKey = 'info';
class MagicMetadata {
// 0 -> visible
@@ -74,6 +75,11 @@ class PubMagicMetadata {
// 1 -> panorama
int? mediaType;
// JSON containing information data for info files
// Contains type (note, physical-record, account-credential, emergency-contact)
// and data (the actual information content)
Map<String, dynamic>? info;
PubMagicMetadata({
this.editedTime,
this.editedName,
@@ -89,6 +95,7 @@ class PubMagicMetadata {
this.dateTime,
this.offsetTime,
this.sv,
this.info,
});
factory PubMagicMetadata.fromEncodedJson(String encodedJson) =>
@@ -114,6 +121,7 @@ class PubMagicMetadata {
dateTime: map[dateTimeKey],
offsetTime: map[offsetTimeKey],
sv: safeParseInt(map[streamVersionKey], streamVersionKey),
info: map[infoKey],
);
}

View File

@@ -97,6 +97,66 @@ class FileUploader {
return completer.future;
}
/// Special upload method for info files that contain only metadata
Future<EnteFile> uploadInfoFile(
EnteFile infoFile,
Collection collection,
) async {
try {
_logger.info('Starting upload of info file: ${infoFile.title}');
// Generate a file key for encryption
final fileKey = CryptoUtil.generateKey();
// Create metadata for the info file
final Map<String, dynamic> metadata = infoFile.metadata;
final encryptedMetadataResult = await CryptoUtil.encryptData(
utf8.encode(jsonEncode(metadata)),
fileKey,
);
final encryptedMetadata = CryptoUtil.bin2base64(
encryptedMetadataResult.encryptedData!,
);
final metadataDecryptionHeader = CryptoUtil.bin2base64(
encryptedMetadataResult.header!,
);
// Encrypt the file key with collection key
final encryptedFileKeyData = CryptoUtil.encryptSync(
fileKey,
CryptoHelper.instance.getCollectionKey(collection),
);
final encryptedKey =
CryptoUtil.bin2base64(encryptedFileKeyData.encryptedData!);
final keyDecryptionNonce =
CryptoUtil.bin2base64(encryptedFileKeyData.nonce!);
final pubMetadataRequest = await getPubMetadataRequest(
infoFile,
{'info': infoFile.pubMagicMetadata.info},
fileKey,
);
// Upload as metadata-only file (no file content or thumbnail)
final uploadedFile = await _uploadInfoFileMetadata(
infoFile,
collection.id,
encryptedKey,
keyDecryptionNonce,
encryptedMetadata,
metadataDecryptionHeader,
pubMetadataRequest,
);
_logger.info('Successfully uploaded info file: ${uploadedFile.title}');
return uploadedFile;
} catch (e, s) {
_logger.severe('Failed to upload info file: ${infoFile.title}', e, s);
rethrow;
}
}
int getCurrentSessionUploadCount() {
return _totalCountInUploadSession;
}
@@ -660,6 +720,43 @@ class FileUploader {
}
}
}
/// Upload method specifically for info files that don't require file content or thumbnails
Future<EnteFile> _uploadInfoFileMetadata(
EnteFile file,
int collectionID,
String encryptedKey,
String keyDecryptionNonce,
String encryptedMetadata,
String metadataDecryptionHeader,
MetadataRequest pubMetadata,
) async {
final request = {
"collectionID": collectionID,
"encryptedKey": encryptedKey,
"keyDecryptionNonce": keyDecryptionNonce,
"metadata": {
"encryptedData": encryptedMetadata,
"decryptionHeader": metadataDecryptionHeader,
},
"pubMagicMetadata": pubMetadata,
};
try {
final response = await _enteDio.post("/files/meta", data: request);
final data = response.data;
file.uploadedFileID = data["id"];
file.collectionID = collectionID;
file.updationTime = data["updationTime"];
file.ownerID = data["ownerID"];
file.encryptedKey = encryptedKey;
file.keyDecryptionNonce = keyDecryptionNonce;
file.metadataDecryptionHeader = metadataDecryptionHeader;
return file;
} catch (e, s) {
_logger.severe("Info file upload failed", e, s);
rethrow;
}
}
}
class FileUploadItem {

View File

@@ -0,0 +1,170 @@
import 'package:locker/models/file_type.dart';
import 'package:locker/models/info/info_item.dart';
import 'package:locker/services/collections/models/collection.dart';
import 'package:locker/services/files/sync/models/file.dart';
import 'package:locker/services/files/sync/models/file_magic.dart';
import 'package:locker/services/files/upload/file_upload_service.dart';
import 'package:logging/logging.dart';
class InfoFileService {
static final InfoFileService instance = InfoFileService._privateConstructor();
InfoFileService._privateConstructor();
final _logger = Logger('InfoFileService');
/// Creates and uploads an info file
Future<EnteFile> createAndUploadInfoFile({
required InfoItem infoItem,
required Collection collection,
}) async {
try {
// Create EnteFile object directly without a physical file
final enteFile = EnteFile();
enteFile.fileType = FileType.info;
enteFile.collectionID = collection.id;
// Set the title based on info type and data
enteFile.title = _getInfoFileTitle(infoItem);
// Set creation and modification times
final now = DateTime.now().millisecondsSinceEpoch;
enteFile.creationTime = now;
enteFile.modificationTime = now;
// Create public magic metadata with info data
final pubMagicMetadata = PubMagicMetadata(
info: {
'type': infoItem.type.name,
'data': infoItem.data.toJson(),
},
noThumb: true, // No thumbnail for info files
);
enteFile.pubMagicMetadata = pubMagicMetadata;
// Upload the file using the special info file upload method
final uploadedFile = await _uploadInfoFile(enteFile, collection);
_logger.info('Successfully uploaded info file: ${uploadedFile.title}');
return uploadedFile;
} catch (e, s) {
_logger.severe('Failed to create and upload info file', e, s);
rethrow;
}
}
/// Updates an existing info file with new data
Future<EnteFile> updateInfoFile({
required EnteFile existingFile,
required InfoItem updatedInfoItem,
}) async {
try {
// Update the public magic metadata
final updatedPubMagicMetadata = existingFile.pubMagicMetadata;
updatedPubMagicMetadata.info = {
'type': updatedInfoItem.type.name,
'data': updatedInfoItem.data.toJson(),
};
// Update the title
final updatedTitle = _getInfoFileTitle(updatedInfoItem);
updatedPubMagicMetadata.editedName = updatedTitle;
updatedPubMagicMetadata.editedTime =
DateTime.now().millisecondsSinceEpoch;
existingFile.pubMagicMetadata = updatedPubMagicMetadata;
// Update metadata on server
// This would call the metadata update service
// TODO: Implement metadata update and sync
_logger.info('Successfully updated info file: $updatedTitle');
return existingFile;
} catch (e, s) {
_logger.severe('Failed to update info file', e, s);
rethrow;
}
}
/// Extracts info data from a file
InfoItem? extractInfoFromFile(EnteFile file) {
try {
if (file.fileType != FileType.info ||
file.pubMagicMetadata.info == null) {
return null;
}
final infoData = file.pubMagicMetadata.info!;
final typeString = infoData['type'] as String?;
final data = infoData['data'] as Map<String, dynamic>?;
if (typeString == null || data == null) {
return null;
}
final infoType = InfoType.values.firstWhere(
(type) => type.name == typeString,
orElse: () => InfoType.note,
);
InfoData infoDataObj;
switch (infoType) {
case InfoType.note:
infoDataObj = PersonalNoteData.fromJson(data);
break;
case InfoType.physicalRecord:
infoDataObj = PhysicalRecordData.fromJson(data);
break;
case InfoType.accountCredential:
infoDataObj = AccountCredentialData.fromJson(data);
break;
case InfoType.emergencyContact:
infoDataObj = EmergencyContactData.fromJson(data);
break;
}
return InfoItem(
type: infoType,
data: infoDataObj,
createdAt: DateTime.now(),
);
} catch (e, s) {
_logger.severe('Failed to extract info from file', e, s);
return null;
}
}
/// Checks if a file is an info file
bool isInfoFile(EnteFile file) {
return file.fileType == FileType.info && file.pubMagicMetadata.info != null;
}
String _getInfoFileTitle(InfoItem infoItem) {
switch (infoItem.type) {
case InfoType.note:
final noteData = infoItem.data as PersonalNoteData;
return noteData.title.isNotEmpty ? noteData.title : 'Personal Note';
case InfoType.physicalRecord:
final recordData = infoItem.data as PhysicalRecordData;
return recordData.name.isNotEmpty ? recordData.name : 'Physical Record';
case InfoType.accountCredential:
final credData = infoItem.data as AccountCredentialData;
return credData.name.isNotEmpty
? '${credData.name} Account'
: 'Account Credential';
case InfoType.emergencyContact:
final contactData = infoItem.data as EmergencyContactData;
return contactData.name.isNotEmpty
? '${contactData.name} (Emergency Contact)'
: 'Emergency Contact';
}
}
/// Special upload method for info files that don't require physical file content
Future<EnteFile> _uploadInfoFile(
EnteFile enteFile,
Collection collection,
) async {
// Use the FileUploader's special method for info files
return await FileUploader.instance.uploadInfoFile(enteFile, collection);
}
}

View File

@@ -0,0 +1,224 @@
import 'package:ente_ui/components/text_input_widget.dart';
import 'package:ente_ui/theme/ente_theme.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
/// A form-compatible wrapper that uses Ente UI TextInputWidget when possible,
/// or falls back to custom implementation for advanced features
class FormTextInputWidget extends StatefulWidget {
final TextEditingController controller;
final String labelText;
final String? hintText;
final String? Function(String?)? validator;
final bool obscureText;
final Widget? suffixIcon;
final int? maxLines;
final TextCapitalization textCapitalization;
final TextInputType? keyboardType;
final bool enabled;
final bool autofocus;
final int? maxLength;
final bool showValidationErrors;
const FormTextInputWidget({
super.key,
required this.controller,
required this.labelText,
this.hintText,
this.validator,
this.obscureText = false,
this.suffixIcon,
this.maxLines = 1,
this.textCapitalization = TextCapitalization.none,
this.keyboardType,
this.enabled = true,
this.autofocus = false,
this.maxLength,
this.showValidationErrors = false,
});
@override
State<FormTextInputWidget> createState() => _FormTextInputWidgetState();
}
class _FormTextInputWidgetState extends State<FormTextInputWidget> {
final GlobalKey<FormFieldState> _formFieldKey = GlobalKey<FormFieldState>();
String? _errorText;
@override
void initState() {
super.initState();
widget.controller.addListener(_onTextChanged);
}
@override
void dispose() {
widget.controller.removeListener(_onTextChanged);
super.dispose();
}
void _onTextChanged() {
if (_errorText != null) {
setState(() {
_errorText = null;
});
}
// Only validate if we should show validation errors
if (widget.showValidationErrors) {
_formFieldKey.currentState?.validate();
}
}
// Check if we can use the UI package's TextInputWidget
bool get _canUseTextInputWidget {
return widget.suffixIcon == null &&
(widget.maxLines ?? 1) == 1 &&
widget.enabled;
}
@override
Widget build(BuildContext context) {
final colorScheme = getEnteColorScheme(context);
final textTheme = getEnteTextTheme(context);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (_canUseTextInputWidget) ...[
// Use the UI package's TextInputWidget for simple cases
TextInputWidget(
label: widget.labelText,
hintText: widget.hintText,
initialValue: widget.controller.text,
isPasswordInput: widget.obscureText,
textCapitalization: widget.textCapitalization,
autoFocus: widget.autofocus,
maxLength: widget.maxLength,
shouldSurfaceExecutionStates: false,
onChange: (value) {
if (widget.controller.text != value) {
widget.controller.text = value;
}
},
),
] else ...[
// Custom implementation for advanced features
if (widget.labelText.isNotEmpty) ...[
Text(widget.labelText),
const SizedBox(height: 4),
],
ClipRRect(
borderRadius: const BorderRadius.all(Radius.circular(8)),
child: Material(
color: Colors.transparent,
child: TextFormField(
controller: widget.controller,
validator: (value) => null, // Handled separately
obscureText: widget.obscureText,
maxLines: widget.obscureText ? 1 : widget.maxLines,
textCapitalization: widget.textCapitalization,
keyboardType: widget.keyboardType,
enabled: widget.enabled,
autofocus: widget.autofocus,
inputFormatters: widget.maxLength != null
? [LengthLimitingTextInputFormatter(widget.maxLength!)]
: null,
style: textTheme.body,
decoration: InputDecoration(
hintText: widget.hintText,
hintStyle:
textTheme.body.copyWith(color: colorScheme.textMuted),
filled: true,
fillColor: colorScheme.fillFaint,
contentPadding: const EdgeInsets.fromLTRB(16, 16, 16, 16),
border: const UnderlineInputBorder(
borderSide: BorderSide.none,
),
enabledBorder: OutlineInputBorder(
borderSide: BorderSide(
color: colorScheme.strokeFaint,
width: 1,
),
borderRadius: BorderRadius.circular(8),
),
focusedBorder: OutlineInputBorder(
borderSide: BorderSide(
color: colorScheme.primary500,
width: 2,
),
borderRadius: BorderRadius.circular(8),
),
errorBorder: OutlineInputBorder(
borderSide: BorderSide(
color: colorScheme.warning500,
width: 1,
),
borderRadius: BorderRadius.circular(8),
),
focusedErrorBorder: OutlineInputBorder(
borderSide: BorderSide(
color: colorScheme.warning500,
width: 2,
),
borderRadius: BorderRadius.circular(8),
),
suffixIcon: widget.suffixIcon != null
? Padding(
padding: const EdgeInsets.symmetric(horizontal: 12),
child: widget.suffixIcon,
)
: null,
suffixIconConstraints: const BoxConstraints(
maxHeight: 24,
maxWidth: 48,
minHeight: 24,
minWidth: 48,
),
errorStyle: const TextStyle(fontSize: 0, height: 0),
),
),
),
),
],
// Custom validation error display (for both cases)
if (_errorText != null && widget.showValidationErrors) ...[
const SizedBox(height: 4),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: Text(
_errorText!,
style: textTheme.mini.copyWith(
color: colorScheme.warning500,
),
),
),
],
// Invisible FormField for validation integration
SizedBox(
height: 0,
child: FormField<String>(
key: _formFieldKey,
validator: (value) {
final error = widget.validator?.call(widget.controller.text);
if (mounted && widget.showValidationErrors) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) {
setState(() {
_errorText = error;
});
}
});
}
return error;
},
builder: (FormFieldState<String> field) {
return const SizedBox.shrink();
},
),
),
],
);
}
}

View File

@@ -0,0 +1,357 @@
import 'package:ente_ui/components/buttons/gradient_button.dart';
import 'package:ente_ui/theme/ente_theme.dart';
import 'package:flutter/material.dart';
import 'package:locker/l10n/l10n.dart';
import 'package:locker/models/info/info_item.dart';
import 'package:locker/services/collections/collections_service.dart';
import 'package:locker/services/collections/models/collection.dart';
import 'package:locker/services/info_file_service.dart';
import 'package:locker/ui/components/collection_selection_widget.dart';
import 'package:locker/ui/components/form_text_input_widget.dart';
class AccountCredentialsPage extends StatefulWidget {
const AccountCredentialsPage({super.key});
@override
State<AccountCredentialsPage> createState() => _AccountCredentialsPageState();
}
class _AccountCredentialsPageState extends State<AccountCredentialsPage> {
final TextEditingController _nameController = TextEditingController();
final TextEditingController _usernameController = TextEditingController();
final TextEditingController _passwordController = TextEditingController();
final TextEditingController _notesController = TextEditingController();
final _formKey = GlobalKey<FormState>();
bool _isLoading = false;
bool _passwordVisible = false;
bool _showValidationErrors = false;
final _passwordFocusNode = FocusNode();
bool _passwordInFocus = false;
// Collection selection state
List<Collection> _availableCollections = [];
Set<int> _selectedCollectionIds = {};
@override
void initState() {
super.initState();
_passwordFocusNode.addListener(() {
setState(() {
_passwordInFocus = _passwordFocusNode.hasFocus;
});
});
_loadCollections();
}
Future<void> _loadCollections() async {
try {
final collections = await CollectionService.instance.getCollections();
setState(() {
_availableCollections = collections;
// Pre-select a default collection if available
if (collections.isNotEmpty) {
final defaultCollection = collections.firstWhere(
(c) => c.name == 'Information',
orElse: () => collections.first,
);
_selectedCollectionIds = {defaultCollection.id};
}
});
} catch (e) {
// Handle error silently or show a message
}
}
void _onToggleCollection(int collectionId) {
setState(() {
if (_selectedCollectionIds.contains(collectionId)) {
_selectedCollectionIds.remove(collectionId);
} else {
// Allow multiple selections
_selectedCollectionIds.add(collectionId);
}
});
}
void _onCollectionsUpdated(List<Collection> updatedCollections) {
setState(() {
_availableCollections = updatedCollections;
});
}
@override
void dispose() {
_nameController.dispose();
_usernameController.dispose();
_passwordController.dispose();
_notesController.dispose();
_passwordFocusNode.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(
context.l10n.accountCredentials,
style: const TextStyle(fontWeight: FontWeight.bold),
),
elevation: 0,
backgroundColor: Theme.of(context).scaffoldBackgroundColor,
foregroundColor: Theme.of(context).textTheme.bodyLarge?.color,
),
body: Form(
key: _formKey,
child: Column(
children: [
Expanded(
child: SingleChildScrollView(
padding: const EdgeInsets.all(24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
context.l10n.accountCredentialsDescription,
style: getEnteTextTheme(context).body.copyWith(
color: Colors.grey[600],
),
),
const SizedBox(height: 32),
FormTextInputWidget(
controller: _nameController,
labelText: context.l10n.credentialName,
hintText: context.l10n.credentialNameHint,
showValidationErrors: _showValidationErrors,
validator: (value) {
if (value == null || value.trim().isEmpty) {
return context.l10n.pleaseEnterAccountName;
}
return null;
},
),
const SizedBox(height: 24),
FormTextInputWidget(
controller: _usernameController,
labelText: context.l10n.username,
hintText: context.l10n.usernameHint,
showValidationErrors: _showValidationErrors,
validator: (value) {
if (value == null || value.trim().isEmpty) {
return context.l10n.pleaseEnterUsername;
}
return null;
},
),
const SizedBox(height: 24),
Text(context.l10n.password),
const SizedBox(height: 4),
Padding(
padding: const EdgeInsets.fromLTRB(0, 0, 0, 0),
child: TextFormField(
controller: _passwordController,
focusNode: _passwordFocusNode,
obscureText: !_passwordVisible,
keyboardType: TextInputType.visiblePassword,
style: getEnteTextTheme(context).body,
decoration: InputDecoration(
fillColor: getEnteColorScheme(context).fillFaint,
filled: true,
hintText: context.l10n.passwordHint,
hintStyle: getEnteTextTheme(context).body.copyWith(
color: getEnteColorScheme(context).textMuted,
),
contentPadding:
const EdgeInsets.fromLTRB(16, 16, 16, 16),
border: const UnderlineInputBorder(
borderSide: BorderSide.none,
),
enabledBorder: OutlineInputBorder(
borderSide: BorderSide(
color: getEnteColorScheme(context).strokeFaint,
width: 1,
),
borderRadius: BorderRadius.circular(8),
),
focusedBorder: OutlineInputBorder(
borderSide: BorderSide(
color: getEnteColorScheme(context).primary500,
width: 2,
),
borderRadius: BorderRadius.circular(8),
),
errorBorder: OutlineInputBorder(
borderSide: BorderSide(
color: getEnteColorScheme(context).warning500,
width: 1,
),
borderRadius: BorderRadius.circular(8),
),
focusedErrorBorder: OutlineInputBorder(
borderSide: BorderSide(
color: getEnteColorScheme(context).warning500,
width: 2,
),
borderRadius: BorderRadius.circular(8),
),
suffixIcon: _passwordInFocus
? IconButton(
icon: Icon(
_passwordVisible
? Icons.visibility
: Icons.visibility_off,
color: Theme.of(context).iconTheme.color,
size: 20,
),
onPressed: () {
setState(() {
_passwordVisible = !_passwordVisible;
});
},
)
: null,
suffixIconConstraints: const BoxConstraints(
maxHeight: 24,
maxWidth: 48,
minHeight: 24,
minWidth: 48,
),
),
validator: _showValidationErrors
? (value) {
if (value == null || value.trim().isEmpty) {
return context.l10n.pleaseEnterPassword;
}
return null;
}
: null,
onChanged: (value) {
if (_showValidationErrors) {
setState(() {});
}
},
),
),
const SizedBox(height: 24),
FormTextInputWidget(
controller: _notesController,
labelText: context.l10n.credentialNotes,
hintText: context.l10n.credentialNotesHint,
maxLines: 5,
showValidationErrors: _showValidationErrors,
),
const SizedBox(height: 24),
CollectionSelectionWidget(
collections: _availableCollections,
selectedCollectionIds: _selectedCollectionIds,
onToggleCollection: _onToggleCollection,
onCollectionsUpdated: _onCollectionsUpdated,
),
],
),
),
),
Container(
padding: const EdgeInsets.all(24),
child: SizedBox(
width: double.infinity,
child: GradientButton(
onTap: _isLoading ? null : _saveRecord,
text: context.l10n.saveRecord,
paddingValue: 16.0,
),
),
),
],
),
),
);
}
Future<void> _saveRecord() async {
setState(() {
_showValidationErrors = true;
});
if (!_formKey.currentState!.validate()) {
return;
}
if (_selectedCollectionIds.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Please select at least one collection'),
backgroundColor: Colors.red,
),
);
return;
}
setState(() {
_isLoading = true;
});
try {
// Create InfoItem for account credentials
final credentialData = AccountCredentialData(
name: _nameController.text.trim(),
username: _usernameController.text.trim(),
password: _passwordController.text.trim(),
notes: _notesController.text.trim().isNotEmpty
? _notesController.text.trim()
: null,
);
final infoItem = InfoItem(
type: InfoType.accountCredential,
data: credentialData,
createdAt: DateTime.now(),
);
// Upload to all selected collections
final selectedCollections = _availableCollections
.where((c) => _selectedCollectionIds.contains(c.id))
.toList();
// Create and upload the info file to each selected collection
for (final collection in selectedCollections) {
await InfoFileService.instance.createAndUploadInfoFile(
infoItem: infoItem,
collection: collection,
);
}
if (mounted) {
Navigator.of(context).pop(); // Go back to information page
// Show success message
final collectionCount = selectedCollections.length;
final message = collectionCount == 1
? context.l10n.recordSavedSuccessfully
: 'Record saved to $collectionCount collections successfully';
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(message),
backgroundColor: Colors.green,
),
);
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('${context.l10n.failedToSaveRecord}: $e'),
backgroundColor: Colors.red,
),
);
}
} finally {
if (mounted) {
setState(() {
_isLoading = false;
});
}
}
}
}

View File

@@ -0,0 +1,258 @@
import 'package:ente_ui/components/buttons/gradient_button.dart';
import 'package:ente_ui/theme/ente_theme.dart';
import 'package:flutter/material.dart';
import 'package:locker/l10n/l10n.dart';
import 'package:locker/models/info/info_item.dart';
import 'package:locker/services/collections/collections_service.dart';
import 'package:locker/services/collections/models/collection.dart';
import 'package:locker/services/info_file_service.dart';
import 'package:locker/ui/components/collection_selection_widget.dart';
import 'package:locker/ui/components/form_text_input_widget.dart';
class EmergencyContactPage extends StatefulWidget {
const EmergencyContactPage({super.key});
@override
State<EmergencyContactPage> createState() => _EmergencyContactPageState();
}
class _EmergencyContactPageState extends State<EmergencyContactPage> {
final TextEditingController _nameController = TextEditingController();
final TextEditingController _contactDetailsController =
TextEditingController();
final TextEditingController _notesController = TextEditingController();
final _formKey = GlobalKey<FormState>();
bool _isLoading = false;
bool _showValidationErrors = false;
// Collection selection state
List<Collection> _availableCollections = [];
Set<int> _selectedCollectionIds = {};
@override
void initState() {
super.initState();
_loadCollections();
}
Future<void> _loadCollections() async {
try {
final collections = await CollectionService.instance.getCollections();
setState(() {
_availableCollections = collections;
// Pre-select a default collection if available
if (collections.isNotEmpty) {
final defaultCollection = collections.firstWhere(
(c) => c.name == 'Information',
orElse: () => collections.first,
);
_selectedCollectionIds = {defaultCollection.id};
}
});
} catch (e) {
// Handle error silently or show a message
}
}
void _onToggleCollection(int collectionId) {
setState(() {
if (_selectedCollectionIds.contains(collectionId)) {
_selectedCollectionIds.remove(collectionId);
} else {
// Allow multiple selections
_selectedCollectionIds.add(collectionId);
}
});
}
void _onCollectionsUpdated(List<Collection> updatedCollections) {
setState(() {
_availableCollections = updatedCollections;
});
}
@override
void dispose() {
_nameController.dispose();
_contactDetailsController.dispose();
_notesController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(
context.l10n.emergencyContact,
style: const TextStyle(fontWeight: FontWeight.bold),
),
elevation: 0,
backgroundColor: Theme.of(context).scaffoldBackgroundColor,
foregroundColor: Theme.of(context).textTheme.bodyLarge?.color,
),
body: Form(
key: _formKey,
child: Column(
children: [
Expanded(
child: SingleChildScrollView(
padding: const EdgeInsets.all(24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
context.l10n.emergencyContactDescription,
style: getEnteTextTheme(context).body.copyWith(
color: Colors.grey[600],
),
),
const SizedBox(height: 32),
FormTextInputWidget(
controller: _nameController,
labelText: context.l10n.contactName,
hintText: context.l10n.contactNameHint,
showValidationErrors: _showValidationErrors,
validator: (value) {
if (value == null || value.trim().isEmpty) {
return context.l10n.pleaseEnterContactName;
}
return null;
},
),
const SizedBox(height: 24),
FormTextInputWidget(
controller: _contactDetailsController,
labelText: context.l10n.contactDetails,
hintText: context.l10n.contactDetailsHint,
maxLines: 3,
showValidationErrors: _showValidationErrors,
validator: (value) {
if (value == null || value.trim().isEmpty) {
return context.l10n.pleaseEnterContactDetails;
}
return null;
},
),
const SizedBox(height: 24),
FormTextInputWidget(
controller: _notesController,
labelText: context.l10n.contactNotes,
hintText: context.l10n.contactNotesHint,
maxLines: 4,
showValidationErrors: _showValidationErrors,
),
const SizedBox(height: 24),
CollectionSelectionWidget(
collections: _availableCollections,
selectedCollectionIds: _selectedCollectionIds,
onToggleCollection: _onToggleCollection,
onCollectionsUpdated: _onCollectionsUpdated,
),
],
),
),
),
Container(
padding: const EdgeInsets.all(24),
child: SizedBox(
width: double.infinity,
child: GradientButton(
onTap: _isLoading ? null : _saveRecord,
text: context.l10n.saveRecord,
paddingValue: 16.0,
),
),
),
],
),
),
);
}
Future<void> _saveRecord() async {
setState(() {
_showValidationErrors = true;
});
if (!_formKey.currentState!.validate()) {
return;
}
if (_selectedCollectionIds.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Please select at least one collection'),
backgroundColor: Colors.red,
),
);
return;
}
setState(() {
_isLoading = true;
});
try {
// Create InfoItem for emergency contact
final contactData = EmergencyContactData(
name: _nameController.text.trim(),
contactDetails: _contactDetailsController.text.trim(),
notes: _notesController.text.trim().isNotEmpty
? _notesController.text.trim()
: null,
);
final infoItem = InfoItem(
type: InfoType.emergencyContact,
data: contactData,
createdAt: DateTime.now(),
);
// Upload to all selected collections
final selectedCollections = _availableCollections
.where((c) => _selectedCollectionIds.contains(c.id))
.toList();
// Create and upload the info file to each selected collection
for (final collection in selectedCollections) {
await InfoFileService.instance.createAndUploadInfoFile(
infoItem: infoItem,
collection: collection,
);
}
if (mounted) {
Navigator.of(context).pop(); // Go back to information page
// Show success message
final collectionCount = selectedCollections.length;
final message = collectionCount == 1
? context.l10n.recordSavedSuccessfully
: 'Record saved to $collectionCount collections successfully';
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(message),
backgroundColor: Colors.green,
),
);
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('${context.l10n.failedToSaveRecord}: $e'),
backgroundColor: Colors.red,
),
);
}
} finally {
if (mounted) {
setState(() {
_isLoading = false;
});
}
}
}
}

View File

@@ -20,6 +20,7 @@ import 'package:locker/ui/components/search_result_view.dart';
import 'package:locker/ui/mixins/search_mixin.dart';
import 'package:locker/ui/pages/all_collections_page.dart';
import 'package:locker/ui/pages/collection_page.dart';
import 'package:locker/ui/pages/information_page.dart';
import "package:locker/ui/pages/settings_page.dart";
import 'package:locker/ui/pages/uploader_page.dart';
import 'package:locker/utils/collection_actions.dart';
@@ -686,34 +687,41 @@ class _HomePageState extends UploaderPageState<HomePage>
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Container(
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 6,
),
decoration: BoxDecoration(
color: getEnteColorScheme(context).fillBase,
borderRadius: BorderRadius.circular(16),
),
child: Text(
context.l10n.createCollectionTooltip,
style: getEnteTextTheme(context).small.copyWith(
color: getEnteColorScheme(context)
.backgroundBase,
),
GestureDetector(
onTap: () {
_toggleFab();
_showInformationDialog();
},
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 6,
),
decoration: BoxDecoration(
color: getEnteColorScheme(context).fillBase,
borderRadius: BorderRadius.circular(16),
),
child: Text(
context.l10n.saveInformation,
style:
getEnteTextTheme(context).small.copyWith(
color: getEnteColorScheme(context)
.backgroundBase,
),
),
),
),
const SizedBox(width: 8),
FloatingActionButton(
heroTag: "createCollection",
heroTag: "information",
mini: true,
onPressed: () {
_toggleFab();
_createCollection();
_showInformationDialog();
},
backgroundColor:
getEnteColorScheme(context).fillBase,
child: const Icon(Icons.create_new_folder),
child: const Icon(Icons.edit_document),
),
],
),
@@ -727,22 +735,29 @@ class _HomePageState extends UploaderPageState<HomePage>
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Container(
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 6,
),
decoration: BoxDecoration(
color: getEnteColorScheme(context).fillBase,
borderRadius: BorderRadius.circular(16),
),
child: Text(
context.l10n.uploadDocumentTooltip,
style:
getEnteTextTheme(context).small.copyWith(
color: getEnteColorScheme(context)
.backgroundBase,
),
GestureDetector(
onTap: () {
_toggleFab();
addFile();
},
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 6,
),
decoration: BoxDecoration(
color: getEnteColorScheme(context).fillBase,
borderRadius: BorderRadius.circular(16),
),
child: Text(
context.l10n.uploadDocumentTooltip,
style: getEnteTextTheme(context)
.small
.copyWith(
color: getEnteColorScheme(context)
.backgroundBase,
),
),
),
),
const SizedBox(width: 8),
@@ -808,4 +823,12 @@ class _HomePageState extends UploaderPageState<HomePage>
});
}
}
void _showInformationDialog() {
Navigator.of(context).push(
MaterialPageRoute(
builder: (context) => const InformationPage(),
),
);
}
}

View File

@@ -0,0 +1,157 @@
import 'package:ente_ui/theme/ente_theme.dart';
import 'package:flutter/material.dart';
import 'package:locker/l10n/l10n.dart';
import 'package:locker/ui/pages/account_credentials_page.dart';
import 'package:locker/ui/pages/emergency_contact_page.dart';
import 'package:locker/ui/pages/personal_note_page.dart';
import 'package:locker/ui/pages/physical_records_page.dart';
enum InformationType {
note,
physicalRecord,
credentials,
emergencyContact,
}
class InformationPage extends StatelessWidget {
const InformationPage({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(
context.l10n.saveInformation,
style: const TextStyle(fontWeight: FontWeight.bold),
),
elevation: 0,
backgroundColor: Theme.of(context).scaffoldBackgroundColor,
foregroundColor: Theme.of(context).textTheme.bodyLarge?.color,
),
body: SingleChildScrollView(
padding: const EdgeInsets.all(24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
context.l10n.informationDescription,
style: getEnteTextTheme(context).body.copyWith(
color: Colors.grey[600],
),
),
const SizedBox(height: 32),
_buildInformationOption(
context,
icon: Icons.notes,
title: context.l10n.personalNote,
description: context.l10n.personalNoteDescription,
type: InformationType.note,
),
const SizedBox(height: 16),
_buildInformationOption(
context,
icon: Icons.folder_outlined,
title: context.l10n.physicalRecords,
description: context.l10n.physicalRecordsDescription,
type: InformationType.physicalRecord,
),
const SizedBox(height: 16),
_buildInformationOption(
context,
icon: Icons.lock,
title: context.l10n.accountCredentials,
description: context.l10n.accountCredentialsDescription,
type: InformationType.credentials,
),
const SizedBox(height: 16),
_buildInformationOption(
context,
icon: Icons.contact_phone,
title: context.l10n.emergencyContact,
description: context.l10n.emergencyContactDescription,
type: InformationType.emergencyContact,
),
const SizedBox(height: 32),
],
),
),
);
}
Widget _buildInformationOption(
BuildContext context, {
required IconData icon,
required String title,
required String description,
required InformationType type,
}) {
return GestureDetector(
onTap: () {
_showInformationForm(context, type);
},
child: Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(8),
color: getEnteColorScheme(context).fillFaint,
),
child: Row(
children: [
Icon(
icon,
size: 24,
color: getEnteColorScheme(context).primary500,
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: getEnteTextTheme(context).body.copyWith(
fontWeight: FontWeight.w500,
),
),
const SizedBox(height: 4),
Text(
description,
style: getEnteTextTheme(context).small.copyWith(
color: Colors.grey[600],
),
),
],
),
),
Icon(
Icons.chevron_right,
color: Colors.grey[400],
),
],
),
),
);
}
void _showInformationForm(BuildContext context, InformationType type) {
Widget page;
switch (type) {
case InformationType.note:
page = const PersonalNotePage();
break;
case InformationType.physicalRecord:
page = const PhysicalRecordsPage();
break;
case InformationType.credentials:
page = const AccountCredentialsPage();
break;
case InformationType.emergencyContact:
page = const EmergencyContactPage();
break;
}
Navigator.of(context).push(
MaterialPageRoute(builder: (context) => page),
);
}
}

View File

@@ -0,0 +1,245 @@
import 'package:ente_ui/components/buttons/gradient_button.dart';
import 'package:ente_ui/theme/ente_theme.dart';
import 'package:flutter/material.dart';
import 'package:locker/l10n/l10n.dart';
import 'package:locker/models/info/info_item.dart';
import 'package:locker/services/collections/collections_service.dart';
import 'package:locker/services/collections/models/collection.dart';
import 'package:locker/services/info_file_service.dart';
import 'package:locker/ui/components/collection_selection_widget.dart';
import 'package:locker/ui/components/form_text_input_widget.dart';
class PersonalNotePage extends StatefulWidget {
const PersonalNotePage({super.key});
@override
State<PersonalNotePage> createState() => _PersonalNotePageState();
}
class _PersonalNotePageState extends State<PersonalNotePage> {
final TextEditingController _nameController = TextEditingController();
final TextEditingController _contentController = TextEditingController();
final _formKey = GlobalKey<FormState>();
bool _isLoading = false;
bool _showValidationErrors = false;
// Collection selection state
List<Collection> _availableCollections = [];
Set<int> _selectedCollectionIds = {};
@override
void initState() {
super.initState();
_loadCollections();
}
Future<void> _loadCollections() async {
try {
final collections = await CollectionService.instance.getCollections();
setState(() {
_availableCollections = collections;
// Pre-select a default collection if available
if (collections.isNotEmpty) {
final defaultCollection = collections.firstWhere(
(c) => c.name == 'Information',
orElse: () => collections.first,
);
_selectedCollectionIds = {defaultCollection.id};
}
});
} catch (e) {
// Handle error silently or show a message
}
}
void _onToggleCollection(int collectionId) {
setState(() {
if (_selectedCollectionIds.contains(collectionId)) {
_selectedCollectionIds.remove(collectionId);
} else {
// Allow multiple selections
_selectedCollectionIds.add(collectionId);
}
});
}
void _onCollectionsUpdated(List<Collection> updatedCollections) {
setState(() {
_availableCollections = updatedCollections;
});
}
@override
void dispose() {
_nameController.dispose();
_contentController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(
context.l10n.personalNote,
style: const TextStyle(fontWeight: FontWeight.bold),
),
elevation: 0,
backgroundColor: Theme.of(context).scaffoldBackgroundColor,
foregroundColor: Theme.of(context).textTheme.bodyLarge?.color,
),
body: Form(
key: _formKey,
child: Column(
children: [
Expanded(
child: SingleChildScrollView(
padding: const EdgeInsets.all(24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
context.l10n.personalNoteDescription,
style: getEnteTextTheme(context).body.copyWith(
color: Colors.grey[600],
),
),
const SizedBox(height: 32),
FormTextInputWidget(
controller: _nameController,
labelText: context.l10n.noteName,
hintText: context.l10n.noteNameHint,
showValidationErrors: _showValidationErrors,
validator: (value) {
if (value == null || value.trim().isEmpty) {
return context.l10n.pleaseEnterNoteName;
}
return null;
},
),
const SizedBox(height: 24),
FormTextInputWidget(
controller: _contentController,
labelText: context.l10n.noteContent,
hintText: context.l10n.noteContentHint,
maxLines: 6,
showValidationErrors: _showValidationErrors,
validator: (value) {
if (value == null || value.trim().isEmpty) {
return context.l10n.pleaseEnterNoteContent;
}
return null;
},
),
const SizedBox(height: 24),
CollectionSelectionWidget(
collections: _availableCollections,
selectedCollectionIds: _selectedCollectionIds,
onToggleCollection: _onToggleCollection,
onCollectionsUpdated: _onCollectionsUpdated,
),
],
),
),
),
Container(
padding: const EdgeInsets.all(24),
child: SizedBox(
width: double.infinity,
child: GradientButton(
onTap: _isLoading ? null : _saveRecord,
text: context.l10n.saveRecord,
paddingValue: 16.0,
),
),
),
],
),
),
);
}
Future<void> _saveRecord() async {
setState(() {
_showValidationErrors = true;
});
if (!_formKey.currentState!.validate()) {
return;
}
if (_selectedCollectionIds.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Please select at least one collection'),
backgroundColor: Colors.red,
),
);
return;
}
setState(() {
_isLoading = true;
});
try {
// Create InfoItem for personal note
final noteData = PersonalNoteData(
title: _nameController.text.trim(),
content: _contentController.text.trim(),
);
final infoItem = InfoItem(
type: InfoType.note,
data: noteData,
createdAt: DateTime.now(),
);
// Upload to all selected collections
final selectedCollections = _availableCollections
.where((c) => _selectedCollectionIds.contains(c.id))
.toList();
// Create and upload the info file to each selected collection
for (final collection in selectedCollections) {
await InfoFileService.instance.createAndUploadInfoFile(
infoItem: infoItem,
collection: collection,
);
}
if (mounted) {
Navigator.of(context)
.pop(); // Close this page and return to information page
// Show success message
final collectionCount = selectedCollections.length;
final message = collectionCount == 1
? context.l10n.recordSavedSuccessfully
: 'Record saved to $collectionCount collections successfully';
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(message),
backgroundColor: Colors.green,
),
);
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('${context.l10n.failedToSaveRecord}: $e'),
backgroundColor: Colors.red,
),
);
}
} finally {
if (mounted) {
setState(() {
_isLoading = false;
});
}
}
}
}

View File

@@ -0,0 +1,257 @@
import 'package:ente_ui/components/buttons/gradient_button.dart';
import 'package:ente_ui/theme/ente_theme.dart';
import 'package:flutter/material.dart';
import 'package:locker/l10n/l10n.dart';
import 'package:locker/models/info/info_item.dart';
import 'package:locker/services/collections/collections_service.dart';
import 'package:locker/services/collections/models/collection.dart';
import 'package:locker/services/info_file_service.dart';
import 'package:locker/ui/components/collection_selection_widget.dart';
import 'package:locker/ui/components/form_text_input_widget.dart';
class PhysicalRecordsPage extends StatefulWidget {
const PhysicalRecordsPage({super.key});
@override
State<PhysicalRecordsPage> createState() => _PhysicalRecordsPageState();
}
class _PhysicalRecordsPageState extends State<PhysicalRecordsPage> {
final TextEditingController _nameController = TextEditingController();
final TextEditingController _locationController = TextEditingController();
final TextEditingController _notesController = TextEditingController();
final _formKey = GlobalKey<FormState>();
bool _isLoading = false;
bool _showValidationErrors = false;
// Collection selection state
List<Collection> _availableCollections = [];
Set<int> _selectedCollectionIds = {};
@override
void initState() {
super.initState();
_loadCollections();
}
Future<void> _loadCollections() async {
try {
final collections = await CollectionService.instance.getCollections();
setState(() {
_availableCollections = collections;
// Pre-select a default collection if available
if (collections.isNotEmpty) {
final defaultCollection = collections.firstWhere(
(c) => c.name == 'Information',
orElse: () => collections.first,
);
_selectedCollectionIds = {defaultCollection.id};
}
});
} catch (e) {
// Handle error silently or show a message
}
}
void _onToggleCollection(int collectionId) {
setState(() {
if (_selectedCollectionIds.contains(collectionId)) {
_selectedCollectionIds.remove(collectionId);
} else {
// Allow multiple selections
_selectedCollectionIds.add(collectionId);
}
});
}
void _onCollectionsUpdated(List<Collection> updatedCollections) {
setState(() {
_availableCollections = updatedCollections;
});
}
@override
void dispose() {
_nameController.dispose();
_locationController.dispose();
_notesController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(
context.l10n.physicalRecords,
style: const TextStyle(fontWeight: FontWeight.bold),
),
elevation: 0,
backgroundColor: Theme.of(context).scaffoldBackgroundColor,
foregroundColor: Theme.of(context).textTheme.bodyLarge?.color,
),
body: Form(
key: _formKey,
child: Column(
children: [
Expanded(
child: SingleChildScrollView(
padding: const EdgeInsets.all(24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
context.l10n.physicalRecordsDescription,
style: getEnteTextTheme(context).body.copyWith(
color: Colors.grey[600],
),
),
const SizedBox(height: 32),
FormTextInputWidget(
controller: _nameController,
labelText: context.l10n.recordName,
hintText: context.l10n.recordNameHint,
showValidationErrors: _showValidationErrors,
validator: (value) {
if (value == null || value.trim().isEmpty) {
return context.l10n.pleaseEnterRecordName;
}
return null;
},
),
const SizedBox(height: 24),
FormTextInputWidget(
controller: _locationController,
labelText: context.l10n.recordLocation,
hintText: context.l10n.recordLocationHint,
maxLines: 3,
showValidationErrors: _showValidationErrors,
validator: (value) {
if (value == null || value.trim().isEmpty) {
return context.l10n.pleaseEnterLocation;
}
return null;
},
),
const SizedBox(height: 24),
FormTextInputWidget(
controller: _notesController,
labelText: context.l10n.recordNotes,
hintText: context.l10n.recordNotesHint,
maxLines: 5,
showValidationErrors: _showValidationErrors,
),
const SizedBox(height: 24),
CollectionSelectionWidget(
collections: _availableCollections,
selectedCollectionIds: _selectedCollectionIds,
onToggleCollection: _onToggleCollection,
onCollectionsUpdated: _onCollectionsUpdated,
),
],
),
),
),
Container(
padding: const EdgeInsets.all(24),
child: SizedBox(
width: double.infinity,
child: GradientButton(
onTap: _isLoading ? null : _saveRecord,
text: context.l10n.saveRecord,
paddingValue: 16.0,
),
),
),
],
),
),
);
}
Future<void> _saveRecord() async {
setState(() {
_showValidationErrors = true;
});
if (!_formKey.currentState!.validate()) {
return;
}
if (_selectedCollectionIds.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Please select at least one collection'),
backgroundColor: Colors.red,
),
);
return;
}
setState(() {
_isLoading = true;
});
try {
// Create InfoItem for physical record
final recordData = PhysicalRecordData(
name: _nameController.text.trim(),
location: _locationController.text.trim(),
notes: _notesController.text.trim().isNotEmpty
? _notesController.text.trim()
: null,
);
final infoItem = InfoItem(
type: InfoType.physicalRecord,
data: recordData,
createdAt: DateTime.now(),
);
// Upload to all selected collections
final selectedCollections = _availableCollections
.where((c) => _selectedCollectionIds.contains(c.id))
.toList();
// Create and upload the info file to each selected collection
for (final collection in selectedCollections) {
await InfoFileService.instance.createAndUploadInfoFile(
infoItem: infoItem,
collection: collection,
);
}
if (mounted) {
Navigator.of(context).pop(); // Go back to information page
// Show success message
final collectionCount = selectedCollections.length;
final message = collectionCount == 1
? context.l10n.recordSavedSuccessfully
: 'Record saved to $collectionCount collections successfully';
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(message),
backgroundColor: Colors.green,
),
);
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('${context.l10n.failedToSaveRecord}: $e'),
backgroundColor: Colors.red,
),
);
}
} finally {
if (mounted) {
setState(() {
_isLoading = false;
});
}
}
}
}

View File

@@ -1,9 +1,7 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
package="io.ente.photos">
<application
tools:replace="android:label"
android:name="${applicationName}"
<application android:name="${applicationName}"
android:label="@string/app_name"
android:icon="@mipmap/icon_green"
android:usesCleartextTraffic="true"

View File

@@ -1,49 +1,36 @@
Store, share and discover your memories with Ente Photos. With end-to-end encryption, only you—and those you share with—can see your photos and videos. Ente Photos has lovingly protected over 200 million memories for people who trust us across all major platforms. Get started with 10 GB free.
Ente is a simple app to backup and share your photos and videos.
Why Ente Photos?
If you've been looking for a privacy-friendly alternative to Google Photos, you've come to the right place. With Ente, they are stored end-to-end encrypted (e2ee). This means that only you can view them.
Ente Photos is designed for those who truly value their memories. With end-to-end encryption and secure backups in three locations, your photos stay truly private and safe. Powerful on-device AI helps you find faces and objects instantly, while curated stories bring cherished memories to the present. Share encrypted albums with loved ones, invite family at no extra cost, and lock sensitive images with a password. Available on mobile, desktop, and web, Ente preserves every pixel of your photos and videos.
We have open-source apps across Android, iOS, web and desktop, and your photos will seamlessly sync between all of them in an end-to-end encrypted (e2ee) manner.
Features:
Ente also makes it simple to share your albums with your loved ones, even if they aren't on Ente. You can share publicly viewable links, where they can view your album and collaborate by adding photos to it, even without an account or app.
END-TO-END ENCRYPTED STORAGE: Your photos and videos are encrypted on your device, and then automatically backed up to the cloud.
Your encrypted data is replicated to 3 different locations, including a fall-out shelter in Paris. We take posterity seriously and make it easy to ensure that your memories outlive you.
SHARE AND COLLABORATE: Let your family or friends add photos and videos to your albums. Everything, end-to-end encrypted.
We are here to make the safest photos app ever, come join our journey!
RELIVE YOUR MEMORIES: Through the stories Ente curates for you, relive your memories from previous years. Easily spread the cheer by sharing them with your loved ones or friends.
FEATURES
- Original quality backups, because every pixel is important
- Family plans, so you can share storage with your family
- Collaborative albums, so you can pool together photos after a trip
- Shared folders, in case you want your partner to enjoy your "Camera" clicks
- Album links, that can be protected with a password
- Ability to free up space, by removing files that have been safely backed up
- Human support, because you're worth it
- Descriptions, so you can caption your memories and find them easily
- Image editor, to add finishing touches
- Favorite, hide and relive your memories, for they are precious
- One-click import from Google, Apple, your hard drive and more
- Dark theme, because your photos look good in it
- 2FA, 3FA, biometric auth
- and a LOT more!
SEARCH FOR ANYONE AND ANYTHING: Using on-device AI, Ente helps you find faces and key elements in a photo, so you can search through your entire library using natural language search.
PERMISSIONS
Ente requests for certain permissions to serve the purpose of a photo storage provider, which can be reviewed here: https://github.com/ente-io/ente/blob/f-droid/mobile/android/permissions.md
INVITE YOUR FAMILY: Invite up to 5 family members to any paid plan at no extra cost. Only your storage space is shared, not your data. Each member will receive their own private space.
PRICING
We don't offer forever free plans, because it is important to us that we remain sustainable and withstand the test of time. Instead we offer affordable plans that you can freely share with your family. You can find more information at ente.io.
AVAILABLE EVERYWHERE: Ente Photos is available on iOS, Android, Windows, Mac, Linux and the web, so you can access your photos and videos from any device you have.
NEVER LOSE YOUR PHOTOS: Ente stores your encrypted backups in 3 secure locations—including an underground facility—so your photos stay safe, no matter what.
EASY IMPORT: Use our powerful desktop app to import data from other providers. If you need any help moving, reach out, and we'll be there.
ORIGINAL QUALITY BACKUPS: All photos and videos are stored in their original quality, including the metadata, without any compression or loss in quality.
APP LOCK: Make sure no one else can see your photos and videos using the built in App Lock. You can set a pin, or use biometrics to lock the app only for yourself.
HIDDEN PHOTOS: Hide your most private photos and videos to the Hidden folder, which is password protected by default.
FREE DEVICE SPACE: Free up your device's space by clearing files that have already been backed, in a single click.
COLLECT PHOTOS: Went to a party and want to collect all the photos in one place? Just share a link with your friends and ask them to upload.
PARTNER SHARING: Share your camera album with your partner so they can automatically see your photos on their device.
LEGACY: Allow trusted contacts to access your account in your absence.
DARK & LIGHT THEMES: Choose the mode that will make your photos pop.
ADDITIONAL SECURITY: Turn on two-factor authentication or set a lock-screen for the app.
OPEN-SOURCE AND AUDITED: Ente Photoss code is open-source, and has been audited by third-party security experts.
HUMAN SUPPORT: We take pride in providing real human support. If you need help, reach out to support@ente.io, and one of us will be there to assist you.
Keep your memories safe and private, with Ente Photos. Get started with 10 GB free.
Visit ente.io to learn more.
SUPPORT
We take pride in offering human support. If you are our paid customer, you can reach out to team@ente.io and expect a response from our team within 24 hours.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 8.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 584 KiB

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 522 KiB

After

Width:  |  Height:  |  Size: 690 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 662 KiB

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 195 KiB

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 521 KiB

After

Width:  |  Height:  |  Size: 853 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 MiB

After

Width:  |  Height:  |  Size: 76 KiB

View File

@@ -1 +1 @@
Backup, Organise, Share - Private photo storage with end-to-end encryption
Ente Photos is an open source photos app, that provides end-to-end encrypted backups for your photos and videos.

View File

@@ -1 +1 @@
Ente Photos - Encrypted photo storage
Ente Photos - Open source, end-to-end encrypted alternative to Google Photos

View File

@@ -8,10 +8,10 @@ allprojects {
google()
jcenter()
mavenCentral()
mavenLocal() // for FDroid
// maven {
// url "${project(':ffmpeg_kit_flutter').projectDir}/libs"
// }
// mavenLocal() // for FDroid
maven {
url "${project(':ffmpeg_kit_flutter').projectDir}/libs"
}
}
}

View File

@@ -1,49 +1,36 @@
Store, share and discover your memories with Ente Photos. With end-to-end encryption, only you—and those you share with—can see your photos and videos. Ente Photos has lovingly protected over 165 million memories for people who trust us across all major platforms. Get started with 10 GB free.
ente is a simple app to backup and share your photos and videos.
Why Ente Photos?
If you've been looking for a privacy-friendly alternative to Google Photos, you've come to the right place. With ente, they are stored end-to-end encrypted (e2ee). This means that only you can view them.
Ente Photos is designed for those who truly value their memories. With end-to-end encryption and secure backups in three locations, your photos stay truly private and safe. Powerful on-device AI helps you find faces and objects instantly, while curated stories bring cherished memories to the present. Share encrypted albums with loved ones, invite family at no extra cost, and lock sensitive images with a password. Available on mobile, desktop, and web, Ente preserves every pixel of your photos and videos.
We have open-source apps across Android, iOS, web and desktop, and your photos will seamlessly sync between all of them in an end-to-end encrypted (e2ee) manner.
Features:
ente also makes it simple to share your albums with your loved ones, even if they aren't on ente. You can share publicly viewable links, where they can view your album and collaborate by adding photos to it, even without an account or app.
END-TO-END ENCRYPTED STORAGE: Your photos and videos are encrypted on your device, and then automatically backed up to the cloud.
Your encrypted data is replicated to 3 different locations, including a fall-out shelter in Paris. We take posterity seriously and make it easy to ensure that your memories outlive you.
SHARE AND COLLABORATE: Let your family or friends add photos and videos to your albums. Everything, end-to-end encrypted.
We are here to make the safest photos app ever, come join our journey!
RELIVE YOUR MEMORIES: Through the stories Ente curates for you, relive your memories from previous years. Easily spread the cheer by sharing them with your loved ones or friends.
FEATURES
- Original quality backups, because every pixel is important
- Family plans, so you can share storage with your family
- Collaborative albums, so you can pool together photos after a trip
- Shared folders, in case you want your partner to enjoy your "Camera" clicks
- Album links, that can be protected with a password
- Ability to free up space, by removing files that have been safely backed up
- Human support, because you're worth it
- Descriptions, so you can caption your memories and find them easily
- Image editor, to add finishing touches
- Favorite, hide and relive your memories, for they are precious
- One-click import from Google, Apple, your hard drive and more
- Dark theme, because your photos look good in it
- 2FA, 3FA, biometric auth
- and a LOT more!
SEARCH FOR ANYONE AND ANYTHING: Using on-device AI, Ente helps you find faces and key elements in a photo, so you can search through your entire library using natural language search.
PERMISSIONS
ente requests for certain permissions to serve the purpose of a photo storage provider, which can be reviewed here: https://github.com/ente-io/ente/blob/f-droid/mobile/android/permissions.md
INVITE YOUR FAMILY: Invite up to 5 family members to any paid plan at no extra cost. Only your storage space is shared, not your data. Each member will receive their own private space.
PRICING
We don't offer forever free plans, because it is important to us that we remain sustainable and withstand the test of time. Instead we offer affordable plans that you can freely share with your family. You can find more information at ente.io.
AVAILABLE EVERYWHERE: Ente Photos is available on iOS, Android, Windows, Mac, Linux and the web, so you can access your photos and videos from any device you have.
NEVER LOSE YOUR PHOTOS: Ente stores your encrypted backups in 3 secure locations—including an underground facility—so your photos stay safe, no matter what.
EASY IMPORT: Use our powerful desktop app to import data from other providers. If you need any help moving, reach out, and we'll be there.
ORIGINAL QUALITY BACKUPS: All photos and videos are stored in their original quality, including the metadata, without any compression or loss in quality.
APP LOCK: Make sure no one else can see your photos and videos using the built in App Lock. You can set a pin, or use biometrics to lock the app only for yourself.
HIDDEN PHOTOS: Hide your most private photos and videos to the Hidden folder, which is password protected by default.
FREE DEVICE SPACE: Free up your device's space by clearing files that have already been backed, in a single click.
COLLECT PHOTOS: Went to a party and want to collect all the photos in one place? Just share a link with your friends and ask them to upload.
PARTNER SHARING: Share your camera album with your partner so they can automatically see your photos on their device.
LEGACY: Allow trusted contacts to access your account in your absence.
DARK & LIGHT THEMES: Choose the mode that will make your photos pop.
ADDITIONAL SECURITY: Turn on two-factor authentication or set a lock-screen for the app.
OPEN-SOURCE AND AUDITED: Ente Photoss code is open-source, and has been audited by third-party security experts.
HUMAN SUPPORT: We take pride in providing real human support. If you need help, reach out to support@ente.io, and one of us will be there to assist you.
Keep your memories safe and private, with Ente Photos. Get started with 10 GB free.
Visit ente.io to learn more.
SUPPORT
We take pride in offering human support. If you are our paid customer, you can reach out to team@ente.io and expect a response from our team within 24 hours.

View File

@@ -1 +1 @@
Backup, Organise, Share - Private photo storage with end-to-end encryption
ente is an end-to-end encrypted photo storage app

View File

@@ -1 +1 @@
Ente Photos - Encrypted photo storage
ente - encrypted photo storage

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 8.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 584 KiB

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 522 KiB

After

Width:  |  Height:  |  Size: 690 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 662 KiB

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 195 KiB

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 521 KiB

After

Width:  |  Height:  |  Size: 853 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 MiB

After

Width:  |  Height:  |  Size: 76 KiB

View File

@@ -1,10 +1,10 @@
import 'dart:async';
import "dart:core";
import 'dart:io';
import "package:adaptive_theme/adaptive_theme.dart";
import "package:computer/computer.dart";
import 'package:ente_crypto/ente_crypto.dart';
import 'package:firebase_messaging/firebase_messaging.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import "package:flutter/rendering.dart";
@@ -36,6 +36,7 @@ import "package:photos/services/machine_learning/face_ml/person/person_service.d
import 'package:photos/services/machine_learning/ml_service.dart';
import 'package:photos/services/machine_learning/semantic_search/semantic_search_service.dart';
import "package:photos/services/notification_service.dart";
import 'package:photos/services/push_service.dart';
import 'package:photos/services/search_service.dart';
import 'package:photos/services/sync/local_sync_service.dart';
import 'package:photos/services/sync/remote_sync_service.dart';
@@ -272,12 +273,11 @@ Future<void> _init(bool isBackground, {String via = ''}) async {
}
if (Platform.isIOS) {
// ignore: unawaited_futures
// PushService.instance.init().then((_) {
// FirebaseMessaging.onBackgroundMessage(
// _firebaseMessagingBackgroundHandler,
// );
// });
PushService.instance.init().then((_) {
FirebaseMessaging.onBackgroundMessage(
_firebaseMessagingBackgroundHandler,
);
}).ignore();
}
_logger.info("PushService/HomeWidget done $tlog");
unawaited(SemanticSearchService.instance.init());
@@ -402,6 +402,31 @@ Future<bool> _isRunningInForeground() async {
(currentTime - kFGTaskDeathTimeoutInMicroseconds);
}
Future<void> _firebaseMessagingBackgroundHandler(RemoteMessage message) async {
final bool isRunningInFG = await _isRunningInForeground(); // hb
final bool isInForeground = AppLifecycleService.instance.isForeground;
if (await _isRunningInForeground()) {
_logger.info(
"Background push received when app is alive and runningInFS: $isRunningInFG inForeground: $isInForeground",
);
if (PushService.shouldSync(message)) {
await _sync('firebaseBgSyncActiveProcess');
}
} else {
// App is dead
runWithLogs(
() async {
_logger.info("Background push received");
await _init(true, via: 'firebasePush');
if (PushService.shouldSync(message)) {
await _sync('firebaseBgSyncNoActiveProcess');
}
},
prefix: "[fbg]",
).ignore();
}
}
Future<void> _logFGHeartBeatInfo(SharedPreferences prefs) async {
final bool isRunningInFG = await _isRunningInForeground();
await prefs.reload();

View File

@@ -4,6 +4,7 @@ import 'package:dio/dio.dart';
import 'package:flutter/material.dart';
// import 'package:flutter/foundation.dart';
// import 'package:flutter_inapp_purchase/flutter_inapp_purchase.dart';
import 'package:in_app_purchase/in_app_purchase.dart';
import 'package:logging/logging.dart';
import 'package:photos/core/errors.dart';
import "package:photos/generated/l10n.dart";
@@ -28,7 +29,6 @@ class BillingService {
late final _logger = Logger("BillingService");
final Dio _enteDio;
// ignore: unused_field
bool _isOnSubscriptionPage = false;
Future<BillingPlans>? _future;
@@ -42,6 +42,23 @@ class BillingService {
// await FlutterInappPurchase.instance.initConnection;
// FlutterInappPurchase.instance.clearTransactionIOS();
// }
InAppPurchase.instance.purchaseStream.listen((purchases) {
if (_isOnSubscriptionPage) {
return;
}
for (final purchase in purchases) {
if (purchase.status == PurchaseStatus.purchased) {
verifySubscription(
purchase.productID,
purchase.verificationData.serverVerificationData,
).then((response) {
InAppPurchase.instance.completePurchase(purchase);
});
} else if (Platform.isIOS && purchase.pendingCompletePurchase) {
InAppPurchase.instance.completePurchase(purchase);
}
}
});
}
void clearCache() {

View File

@@ -1,6 +1,24 @@
import 'package:flutter/cupertino.dart';
import 'package:photos/core/configuration.dart';
import "package:photos/service_locator.dart";
import "package:photos/ui/payment/store_subscription_page.dart";
import 'package:photos/ui/payment/stripe_subscription_page.dart';
StatefulWidget getSubscriptionPage({bool isOnBoarding = false}) {
return StripeSubscriptionPage(isOnboarding: isOnBoarding);
if (updateService.isIndependentFlavor()) {
return StripeSubscriptionPage(isOnboarding: isOnBoarding);
}
if (flagService.enableStripe && _isUserCreatedPostStripeSupport()) {
return StripeSubscriptionPage(isOnboarding: isOnBoarding);
} else {
return StoreSubscriptionPage(isOnboarding: isOnBoarding);
}
}
// return true if the user was created after we added support for stripe payment
// on frame. We do this check to avoid showing Stripe payment option for earlier
// users who might have paid via playStore. This method should be removed once
// we have better handling for active play/app store subscription & stripe plans.
bool _isUserCreatedPostStripeSupport() {
return Configuration.instance.getUserID()! > 1580559962386460;
}

View File

@@ -9,14 +9,22 @@ packages:
url: "https://pub.dev"
source: hosted
version: "85.0.0"
_flutterfire_internals:
dependency: transitive
description:
name: _flutterfire_internals
sha256: a5788040810bd84400bc209913fbc40f388cded7cdf95ee2f5d2bff7e38d5241
url: "https://pub.dev"
source: hosted
version: "1.3.58"
adaptive_theme:
dependency: "direct main"
description:
name: adaptive_theme
sha256: "41b8af1bb5f3fb87db1aed8c8a2dc3eab79116d20793b5c9b6d2e8809402de9a"
sha256: caa49b4c73b681bf12a641dff77aa1383262a00cf38b9d1a25b180e275ba5ab9
url: "https://pub.dev"
source: hosted
version: "3.7.1+2"
version: "3.7.0"
analyzer:
dependency: transitive
description:
@@ -29,10 +37,10 @@ packages:
dependency: "direct main"
description:
name: android_intent_plus
sha256: "2329378af63f49b985cb2e110ac784d08374f1e2b1984be77ba9325b1c8cce11"
sha256: dfc1fd3a577205ae8f11e990fb4ece8c90cceabbee56fcf48e463ecf0bd6aae3
url: "https://pub.dev"
source: hosted
version: "5.3.1"
version: "5.3.0"
animated_list_plus:
dependency: "direct main"
description:
@@ -61,10 +69,10 @@ packages:
dependency: "direct main"
description:
name: app_links
sha256: "5f88447519add627fe1cbcab4fd1da3d4fed15b9baf29f28b22535c95ecee3e8"
sha256: "85ed8fc1d25a76475914fff28cc994653bd900bc2c26e4b57a49e097febb54ba"
url: "https://pub.dev"
source: hosted
version: "6.4.1"
version: "6.4.0"
app_links_linux:
dependency: transitive
description:
@@ -158,10 +166,10 @@ packages:
dependency: transitive
description:
name: build
sha256: ce76b1d48875e3233fde17717c23d1f60a91cc631597e49a400c89b475395b1d
sha256: "7d95cbbb1526ab5ae977df9b4cc660963b9b27f6d1075c0b34653868911385e4"
url: "https://pub.dev"
source: hosted
version: "3.1.0"
version: "3.0.0"
build_cli_annotations:
dependency: transitive
description:
@@ -174,10 +182,10 @@ packages:
dependency: transitive
description:
name: build_config
sha256: "4f64382b97504dc2fcdf487d5aae33418e08b4703fc21249e4db6d804a4d0187"
sha256: "4ae2de3e1e67ea270081eaee972e1bd8f027d459f249e0f1186730784c2e7e33"
url: "https://pub.dev"
source: hosted
version: "1.2.0"
version: "1.1.2"
build_daemon:
dependency: transitive
description:
@@ -190,26 +198,26 @@ packages:
dependency: transitive
description:
name: build_resolvers
sha256: d1d57f7807debd7349b4726a19fd32ec8bc177c71ad0febf91a20f84cd2d4b46
sha256: "38c9c339333a09b090a638849a4c56e70a404c6bdd3b511493addfbc113b60c2"
url: "https://pub.dev"
source: hosted
version: "3.0.3"
version: "3.0.0"
build_runner:
dependency: "direct dev"
description:
name: build_runner
sha256: b24597fceb695969d47025c958f3837f9f0122e237c6a22cb082a5ac66c3ca30
sha256: b971d4a1c789eba7be3e6fe6ce5e5b50fd3719e3cb485b3fad6d04358304351d
url: "https://pub.dev"
source: hosted
version: "2.7.1"
version: "2.6.0"
build_runner_core:
dependency: transitive
description:
name: build_runner_core
sha256: "066dda7f73d8eb48ba630a55acb50c4a84a2e6b453b1cb4567f581729e794f7b"
sha256: c04e612ca801cd0928ccdb891c263a2b1391cb27940a5ea5afcf9ba894de5d62
url: "https://pub.dev"
source: hosted
version: "9.3.1"
version: "9.2.0"
built_collection:
dependency: transitive
description:
@@ -222,10 +230,10 @@ packages:
dependency: transitive
description:
name: built_value
sha256: a30f0a0e38671e89a492c44d005b5545b830a961575bbd8336d42869ff71066d
sha256: "082001b5c3dc495d4a42f1d5789990505df20d8547d42507c29050af6933ee27"
url: "https://pub.dev"
source: hosted
version: "8.12.0"
version: "8.10.1"
cached_network_image:
dependency: "direct main"
description:
@@ -271,10 +279,10 @@ packages:
dependency: transitive
description:
name: checked_yaml
sha256: "959525d3162f249993882720d52b7e0c833978df229be20702b33d48d91de70f"
sha256: feb6bed21949061731a7a75fc5d2aa727cf160b91af9a3e464c5e3a32e28b5ff
url: "https://pub.dev"
source: hosted
version: "2.0.4"
version: "2.0.3"
chewie:
dependency: "direct main"
description:
@@ -337,10 +345,10 @@ packages:
dependency: "direct main"
description:
name: connectivity_plus
sha256: b5e72753cf63becce2c61fd04dfe0f1c430cc5278b53a1342dc5ad839eab29ec
sha256: "051849e2bd7c7b3bc5844ea0d096609ddc3a859890ec3a9ac4a65a2620cc1f99"
url: "https://pub.dev"
source: hosted
version: "6.1.5"
version: "6.1.4"
connectivity_plus_platform_interface:
dependency: transitive
description:
@@ -361,18 +369,18 @@ packages:
dependency: transitive
description:
name: coverage
sha256: "5da775aa218eaf2151c721b16c01c7676fbfdd99cebba2bf64e8b807a28ff94d"
sha256: aa07dbe5f2294c827b7edb9a87bba44a9c15a3cc81bc8da2ca19b37322d30080
url: "https://pub.dev"
source: hosted
version: "1.15.0"
version: "1.14.1"
cronet_http:
dependency: transitive
description:
name: cronet_http
sha256: "1b99ad5ae81aa9d2f12900e5f17d3681f3828629bb7f7fe7ad88076a34209840"
sha256: "5ed075c59b2d4bd43af4e73d906b8082e98ecd2af9c625327370ef28361bf635"
url: "https://pub.dev"
source: hosted
version: "1.5.0"
version: "1.3.4"
cross_file:
dependency: transitive
description:
@@ -401,10 +409,10 @@ packages:
dependency: transitive
description:
name: cupertino_http
sha256: "72187f715837290a63479a5b0ae709f4fedad0ed6bd0441c275eceaa02d5abae"
sha256: "8fb9e2c36d0732d9d96abd76683406b57e78a2514e27c962e0c603dbe6f2e3f8"
url: "https://pub.dev"
source: hosted
version: "2.3.0"
version: "2.2.0"
cupertino_icons:
dependency: "direct main"
description:
@@ -425,10 +433,10 @@ packages:
dependency: transitive
description:
name: dart_style
sha256: "8a0e5fba27e8ee025d2ffb4ee820b4e6e2cf5e4246a6b1a477eb66866947e0bb"
sha256: "5b236382b47ee411741447c1f1e111459c941ea1b3f2b540dde54c210a3662af"
url: "https://pub.dev"
source: hosted
version: "3.1.1"
version: "3.1.0"
dart_ui_isolate:
dependency: "direct main"
description:
@@ -473,10 +481,10 @@ packages:
dependency: "direct main"
description:
name: dio
sha256: d90ee57923d1828ac14e492ca49440f65477f4bb1263575900be731a3dac66a9
sha256: "253a18bbd4851fecba42f7343a1df3a9a4c1d31a2c1b37e221086b4fa8c8dbc9"
url: "https://pub.dev"
source: hosted
version: "5.9.0"
version: "5.8.0+1"
dio_web_adapter:
dependency: transitive
description:
@@ -663,7 +671,7 @@ packages:
description:
path: "flutter/flutter"
ref: remove-event-sub
resolved-ref: b7aac7903f70dce6d71506e221066af1a53ec7fc
resolved-ref: "2c0f34797df830ef61e6ed479583b368c342cb37"
url: "https://github.com/ente-io/ffmpeg-kit"
source: git
version: "6.0.3"
@@ -672,7 +680,7 @@ packages:
description:
path: "flutter/flutter_platform_interface"
ref: remove-event-sub
resolved-ref: b7aac7903f70dce6d71506e221066af1a53ec7fc
resolved-ref: "2c0f34797df830ef61e6ed479583b368c342cb37"
url: "https://github.com/ente-io/ffmpeg-kit"
source: git
version: "0.2.1"
@@ -700,38 +708,54 @@ packages:
url: "https://pub.dev"
source: hosted
version: "0.2.14"
file_selector_linux:
dependency: transitive
firebase_core:
dependency: "direct main"
description:
name: file_selector_linux
sha256: "54cbbd957e1156d29548c7d9b9ec0c0ebb6de0a90452198683a7d23aed617a33"
name: firebase_core
sha256: c6e8a6bf883d8ddd0dec39be90872daca65beaa6f4cff0051ed3b16c56b82e9f
url: "https://pub.dev"
source: hosted
version: "0.9.3+2"
file_selector_macos:
version: "3.15.1"
firebase_core_platform_interface:
dependency: transitive
description:
name: file_selector_macos
sha256: "19124ff4a3d8864fdc62072b6a2ef6c222d55a3404fe14893a3c02744907b60c"
name: firebase_core_platform_interface
sha256: "5dbc900677dcbe5873d22ad7fbd64b047750124f1f9b7ebe2a33b9ddccc838eb"
url: "https://pub.dev"
source: hosted
version: "0.9.4+4"
file_selector_platform_interface:
version: "6.0.0"
firebase_core_web:
dependency: transitive
description:
name: file_selector_platform_interface
sha256: a3994c26f10378a039faa11de174d7b78eb8f79e4dd0af2a451410c1a5c3f66b
name: firebase_core_web
sha256: "0ed0dc292e8f9ac50992e2394e9d336a0275b6ae400d64163fdf0a8a8b556c37"
url: "https://pub.dev"
source: hosted
version: "2.6.2"
file_selector_windows:
version: "2.24.1"
firebase_messaging:
dependency: "direct main"
description:
name: firebase_messaging
sha256: "0f3363f97672eb9f65609fa00ed2f62cc8ec93e7e2d4def99726f9165d3d8a73"
url: "https://pub.dev"
source: hosted
version: "15.2.9"
firebase_messaging_platform_interface:
dependency: transitive
description:
name: file_selector_windows
sha256: "320fcfb6f33caa90f0b58380489fc5ac05d99ee94b61aa96ec2bff0ba81d3c2b"
name: firebase_messaging_platform_interface
sha256: "7a05ef119a14c5f6a9440d1e0223bcba20c8daf555450e119c4c477bf2c3baa9"
url: "https://pub.dev"
source: hosted
version: "0.9.3+4"
version: "4.6.9"
firebase_messaging_web:
dependency: transitive
description:
name: firebase_messaging_web
sha256: a4547f76da2a905190f899eb4d0150e1d0fd52206fce469d9f05ae15bb68b2c5
url: "https://pub.dev"
source: hosted
version: "3.10.9"
fixnum:
dependency: "direct main"
description:
@@ -930,10 +954,10 @@ packages:
dependency: "direct main"
description:
name: flutter_local_notifications
sha256: a9966c850de5e445331b854fa42df96a8020066d67f125a5964cbc6556643f68
sha256: edae0c34573233ab03f5ba1f07465e55c384743893042cb19e010b4ee8541c12
url: "https://pub.dev"
source: hosted
version: "19.4.1"
version: "19.3.0"
flutter_local_notifications_linux:
dependency: transitive
description:
@@ -954,10 +978,10 @@ packages:
dependency: transitive
description:
name: flutter_local_notifications_windows
sha256: ed46d7ae4ec9d19e4c8fa2badac5fe27ba87a3fe387343ce726f927af074ec98
sha256: f8fc0652a601f83419d623c85723a3e82ad81f92b33eaa9bcc21ea1b94773e6e
url: "https://pub.dev"
source: hosted
version: "1.0.2"
version: "1.0.0"
flutter_localizations:
dependency: "direct main"
description: flutter
@@ -1007,10 +1031,10 @@ packages:
dependency: transitive
description:
name: flutter_plugin_android_lifecycle
sha256: b0694b7fb1689b0e6cc193b3f1fcac6423c4f93c74fb20b806c6b6f196db0c31
sha256: f948e346c12f8d5480d2825e03de228d0eb8c3a737e4cdaa122267b89c022b5e
url: "https://pub.dev"
source: hosted
version: "2.0.30"
version: "2.0.28"
flutter_rust_bridge:
dependency: "direct main"
description:
@@ -1088,10 +1112,10 @@ packages:
dependency: "direct main"
description:
name: flutter_spinkit
sha256: "77850df57c00dc218bfe96071d576a8babec24cf58b2ed121c83cca4a2fdce7f"
sha256: d2696eed13732831414595b98863260e33e8882fc069ee80ec35d4ac9ddb0472
url: "https://pub.dev"
source: hosted
version: "5.2.2"
version: "5.2.1"
flutter_staggered_grid_view:
dependency: "direct main"
description:
@@ -1104,10 +1128,10 @@ packages:
dependency: "direct main"
description:
name: flutter_svg
sha256: b9c2ad5872518a27507ab432d1fb97e8813b05f0fc693f9d40fad06d073e0678
sha256: cd57f7969b4679317c17af6fd16ee233c1e60a82ed209d8a475c54fd6fd6f845
url: "https://pub.dev"
source: hosted
version: "2.2.1"
version: "2.2.0"
flutter_test:
dependency: "direct dev"
description: flutter
@@ -1231,10 +1255,10 @@ packages:
dependency: "direct main"
description:
name: http
sha256: bb2ce4590bc2667c96f318d68cac1b5a7987ec819351d32b1c987239a815e007
sha256: "2c11f3f94c687ee9bad77c171151672986360b2b001d109814ee7140b2cf261b"
url: "https://pub.dev"
source: hosted
version: "1.5.0"
version: "1.4.0"
http_client_helper:
dependency: transitive
description:
@@ -1275,102 +1299,38 @@ packages:
url: "https://pub.dev"
source: hosted
version: "4.5.4"
image_editor:
in_app_purchase:
dependency: "direct main"
description:
name: image_editor
sha256: "38070067264fd9fea4328ca630d2ff7bd65ebe6aa4ed375d983b732d2ae7146b"
name: in_app_purchase
sha256: "5cddd7f463f3bddb1d37a72b95066e840d5822d66291331d7f8f05ce32c24b6c"
url: "https://pub.dev"
source: hosted
version: "1.6.0"
image_editor_common:
version: "3.2.3"
in_app_purchase_android:
dependency: transitive
description:
name: image_editor_common
sha256: "93d2f5c8b636f862775dd62a9ec20d09c8272598daa02f935955a4640e1844ee"
name: in_app_purchase_android
sha256: fd76e5612da6facadcfe8a3477da092908227260a9f6ec7db9a66dd989c69b02
url: "https://pub.dev"
source: hosted
version: "1.2.0"
image_editor_ohos:
version: "0.4.0+2"
in_app_purchase_platform_interface:
dependency: transitive
description:
name: image_editor_ohos
sha256: "06756859586d5acefec6e3b4f356f9b1ce05ef09213bcb9a0ce1680ecea2d054"
name: in_app_purchase_platform_interface
sha256: "1d353d38251da5b9fea6635c0ebfc6bb17a2d28d0e86ea5e083bf64244f1fb4c"
url: "https://pub.dev"
source: hosted
version: "0.0.9"
image_editor_platform_interface:
version: "1.4.0"
in_app_purchase_storekit:
dependency: transitive
description:
name: image_editor_platform_interface
sha256: "474517efc770464f7d99942472d8cfb369a3c378e95466ec17f74d2b80bd40de"
name: in_app_purchase_storekit
sha256: ceddd5a70d268f762d29993ed470054bc2baf8793e41800bc82cde05110260d0
url: "https://pub.dev"
source: hosted
version: "1.1.0"
image_picker:
dependency: "direct main"
description:
name: image_picker
sha256: "736eb56a911cf24d1859315ad09ddec0b66104bc41a7f8c5b96b4e2620cf5041"
url: "https://pub.dev"
source: hosted
version: "1.2.0"
image_picker_android:
dependency: transitive
description:
name: image_picker_android
sha256: "28f3987ca0ec702d346eae1d90eda59603a2101b52f1e234ded62cff1d5cfa6e"
url: "https://pub.dev"
source: hosted
version: "0.8.13+1"
image_picker_for_web:
dependency: transitive
description:
name: image_picker_for_web
sha256: "40c2a6a0da15556dc0f8e38a3246064a971a9f512386c3339b89f76db87269b6"
url: "https://pub.dev"
source: hosted
version: "3.1.0"
image_picker_ios:
dependency: transitive
description:
name: image_picker_ios
sha256: eb06fe30bab4c4497bad449b66448f50edcc695f1c59408e78aa3a8059eb8f0e
url: "https://pub.dev"
source: hosted
version: "0.8.13"
image_picker_linux:
dependency: transitive
description:
name: image_picker_linux
sha256: "1f81c5f2046b9ab724f85523e4af65be1d47b038160a8c8deed909762c308ed4"
url: "https://pub.dev"
source: hosted
version: "0.2.2"
image_picker_macos:
dependency: transitive
description:
name: image_picker_macos
sha256: d58cd9d67793d52beefd6585b12050af0a7663c0c2a6ece0fb110a35d6955e04
url: "https://pub.dev"
source: hosted
version: "0.2.2"
image_picker_platform_interface:
dependency: transitive
description:
name: image_picker_platform_interface
sha256: "9f143b0dba3e459553209e20cc425c9801af48e6dfa4f01a0fcf927be3f41665"
url: "https://pub.dev"
source: hosted
version: "2.11.0"
image_picker_windows:
dependency: transitive
description:
name: image_picker_windows
sha256: d248c86554a72b5495a31c56f060cf73a41c7ff541689327b1a7dbccc33adfae
url: "https://pub.dev"
source: hosted
version: "0.2.2"
version: "0.4.2"
integration_test:
dependency: "direct dev"
description: flutter
@@ -1388,10 +1348,10 @@ packages:
dependency: "direct dev"
description:
name: intl_utils
sha256: da67ac187b521445d745f7c68e7254c2090f6c3c9081c93cd515480af9e59569
sha256: "3b2655259dbebd26e3b1466d0839f389dc59a275337e1b760ac645bc7814d2d3"
url: "https://pub.dev"
source: hosted
version: "2.8.11"
version: "2.8.10"
io:
dependency: transitive
description:
@@ -1412,10 +1372,10 @@ packages:
dependency: transitive
description:
name: jni
sha256: d2c361082d554d4593c3012e26f6b188f902acd291330f13d6427641a92b3da1
sha256: "459727a9daf91bdfb39b014cf3c186cf77f0136124a274ac83c186e12262ac4e"
url: "https://pub.dev"
source: hosted
version: "0.14.2"
version: "0.12.2"
js:
dependency: "direct overridden"
description:
@@ -1436,10 +1396,10 @@ packages:
dependency: "direct dev"
description:
name: json_serializable
sha256: "33a040668b31b320aafa4822b7b1e177e163fc3c1e835c6750319d4ab23aa6fe"
sha256: ce2cf974ccdee13be2a510832d7fba0b94b364e0b0395dee42abaa51b855be27
url: "https://pub.dev"
source: hosted
version: "6.11.1"
version: "6.10.0"
latlong2:
dependency: "direct main"
description:
@@ -1516,18 +1476,18 @@ packages:
dependency: "direct main"
description:
name: local_auth_android
sha256: "48924f4a8b3cc45994ad5993e2e232d3b00788a305c1bf1c7db32cef281ce9a3"
sha256: "63ad7ca6396290626dc0cb34725a939e4cfe965d80d36112f08d49cf13a8136e"
url: "https://pub.dev"
source: hosted
version: "1.0.52"
version: "1.0.49"
local_auth_darwin:
dependency: transitive
description:
name: local_auth_darwin
sha256: "0e9706a8543a4a2eee60346294d6a633dd7c3ee60fae6b752570457c4ff32055"
sha256: "25163ce60a5a6c468cf7a0e3dc8a165f824cabc2aa9e39a5e9fc5c2311b7686f"
url: "https://pub.dev"
source: hosted
version: "1.6.0"
version: "1.5.0"
local_auth_ios:
dependency: "direct main"
description:
@@ -1556,10 +1516,10 @@ packages:
dependency: transitive
description:
name: logger
sha256: "55d6c23a6c15db14920e037fe7e0dc32e7cdaf3b64b4b25df2d541b5b6b81c0c"
sha256: "2621da01aabaf223f8f961e751f2c943dbb374dc3559b982f200ccedadaa6999"
url: "https://pub.dev"
source: hosted
version: "2.6.1"
version: "2.6.0"
logging:
dependency: "direct main"
description:
@@ -1789,10 +1749,10 @@ packages:
dependency: "direct main"
description:
name: native_dio_adapter
sha256: "1c51bd42027861d27ccad462ba0903f5e3197461cc6d59a0bb8658cb5ad7bd01"
sha256: "7420bc9517b2abe09810199a19924617b45690a44ecfb0616ac9babc11875c03"
url: "https://pub.dev"
source: hosted
version: "1.5.0"
version: "1.4.0"
native_video_player:
dependency: "direct main"
description:
@@ -1878,18 +1838,18 @@ packages:
dependency: "direct main"
description:
name: package_info_plus
sha256: "16eee997588c60225bda0488b6dcfac69280a6b7a3cf02c741895dd370a02968"
sha256: "7976bfe4c583170d6cdc7077e3237560b364149fcd268b5f53d95a991963b191"
url: "https://pub.dev"
source: hosted
version: "8.3.1"
version: "8.3.0"
package_info_plus_platform_interface:
dependency: transitive
description:
name: package_info_plus_platform_interface
sha256: "202a487f08836a592a6bd4f901ac69b3a8f146af552bbd14407b6b41e1c3f086"
sha256: "6c935fb612dff8e3cc9632c2b301720c77450a126114126ffaafe28d2e87956c"
url: "https://pub.dev"
source: hosted
version: "3.2.1"
version: "3.2.0"
panorama:
dependency: "direct main"
description:
@@ -1943,18 +1903,18 @@ packages:
dependency: transitive
description:
name: path_provider_android
sha256: "993381400e94d18469750e5b9dcb8206f15bc09f9da86b9e44a9b0092a0066db"
sha256: d0d310befe2c8ab9e7f393288ccbb11b60c019c6b5afc21973eeee4dda2b35e9
url: "https://pub.dev"
source: hosted
version: "2.2.18"
version: "2.2.17"
path_provider_foundation:
dependency: "direct main"
description:
name: path_provider_foundation
sha256: "16eef174aacb07e09c351502740fa6254c165757638eba1e9116b0a781201bbd"
sha256: "4843174df4d288f5e29185bd6e72a6fbdf5a4a4602717eed565497429f179942"
url: "https://pub.dev"
source: hosted
version: "2.4.2"
version: "2.4.1"
path_provider_linux:
dependency: transitive
description:
@@ -2031,10 +1991,10 @@ packages:
dependency: transitive
description:
name: petitparser
sha256: "1a97266a94f7350d30ae522c0af07890c70b8e62c71e8e3920d1db4d23c057d1"
sha256: "07c8f0b1913bcde1ff0d26e57ace2f3012ccbf2b204e070290dad3bb22797646"
url: "https://pub.dev"
source: hosted
version: "7.0.1"
version: "6.1.0"
photo_manager:
dependency: "direct main"
description:
@@ -2063,10 +2023,10 @@ packages:
dependency: "direct main"
description:
name: pinput
sha256: c41f42ee301505ae2375ec32871c985d3717bf8aee845620465b286e0140aad2
sha256: "8a73be426a91fefec90a7f130763ca39772d547e92f19a827cf4aa02e323d35a"
url: "https://pub.dev"
source: hosted
version: "5.0.2"
version: "5.0.1"
platform:
dependency: transitive
description:
@@ -2160,10 +2120,10 @@ packages:
dependency: transitive
description:
name: provider
sha256: "4e82183fa20e5ca25703ead7e05de9e4cceed1fbd1eadc1ac3cb6f565a09f272"
sha256: "4abbd070a04e9ddc287673bf5a030c7ca8b685ff70218720abab8b092f53dd84"
url: "https://pub.dev"
source: hosted
version: "6.1.5+1"
version: "6.1.5"
pub_semver:
dependency: transitive
description:
@@ -2248,10 +2208,10 @@ packages:
dependency: transitive
description:
name: screen_brightness_android
sha256: d34f5321abd03bc3474f4c381f53d189117eba0b039eac1916aa92cca5fd0a96
sha256: fb5fa43cb89d0c9b8534556c427db1e97e46594ac5d66ebdcf16063b773d54ed
url: "https://pub.dev"
source: hosted
version: "2.1.3"
version: "2.1.2"
screen_brightness_platform_interface:
dependency: transitive
description:
@@ -2296,18 +2256,18 @@ packages:
dependency: "direct main"
description:
name: share_plus
sha256: d7dc0630a923883c6328ca31b89aa682bacbf2f8304162d29f7c6aaff03a27a1
sha256: b2961506569e28948d75ec346c28775bb111986bb69dc6a20754a457e3d97fa0
url: "https://pub.dev"
source: hosted
version: "11.1.0"
version: "11.0.0"
share_plus_platform_interface:
dependency: transitive
description:
name: share_plus_platform_interface
sha256: "88023e53a13429bd65d8e85e11a9b484f49d4c190abbd96c7932b74d6927cc9a"
sha256: "1032d392bc5d2095a77447a805aa3f804d2ae6a4d5eef5e6ebb3bd94c1bc19ef"
url: "https://pub.dev"
source: hosted
version: "6.1.0"
version: "6.0.0"
shared_preferences:
dependency: "direct main"
description:
@@ -2320,10 +2280,10 @@ packages:
dependency: transitive
description:
name: shared_preferences_android
sha256: a2608114b1ffdcbc9c120eb71a0e207c71da56202852d4aab8a5e30a82269e74
sha256: "20cbd561f743a342c76c151d6ddb93a9ce6005751e7aa458baad3858bfbfb6ac"
url: "https://pub.dev"
source: hosted
version: "2.4.12"
version: "2.4.10"
shared_preferences_foundation:
dependency: transitive
description:
@@ -2413,10 +2373,10 @@ packages:
dependency: transitive
description:
name: source_helper
sha256: "6a3c6cc82073a8797f8c4dc4572146114a39652851c157db37e964d9c7038723"
sha256: a447acb083d3a5ef17f983dd36201aeea33fedadb3228fa831f2f0c92f0f3aca
url: "https://pub.dev"
source: hosted
version: "1.3.8"
version: "1.3.7"
source_map_stack_trace:
dependency: transitive
description:
@@ -2469,10 +2429,10 @@ packages:
dependency: transitive
description:
name: sqflite_common
sha256: "6ef422a4525ecc601db6c0a2233ff448c731307906e92cabc9ba292afaae16a6"
sha256: "84731e8bfd8303a3389903e01fb2141b6e59b5973cacbb0929021df08dddbe8b"
url: "https://pub.dev"
source: hosted
version: "2.5.6"
version: "2.5.5"
sqflite_darwin:
dependency: transitive
description:
@@ -2501,34 +2461,34 @@ packages:
dependency: transitive
description:
name: sqlite3
sha256: f393d92c71bdcc118d6203d07c991b9be0f84b1a6f89dd4f7eed348131329924
sha256: c0503c69b44d5714e6abbf4c1f51a3c3cc42b75ce785f44404765e4635481d38
url: "https://pub.dev"
source: hosted
version: "2.9.0"
version: "2.7.6"
sqlite3_flutter_libs:
dependency: "direct main"
description:
name: sqlite3_flutter_libs
sha256: "2b03273e71867a8a4d030861fc21706200debe5c5858a4b9e58f4a1c129586a4"
sha256: e07232b998755fe795655c56d1f5426e0190c9c435e1752d39e7b1cd33699c71
url: "https://pub.dev"
source: hosted
version: "0.5.39"
version: "0.5.34"
sqlite3_web:
dependency: transitive
description:
name: sqlite3_web
sha256: "0f6ebcb4992d1892ac5c8b5ecd22a458ab9c5eb6428b11ae5ecb5d63545844da"
sha256: "967e076442f7e1233bd7241ca61f3efe4c7fc168dac0f38411bdb3bdf471eb3c"
url: "https://pub.dev"
source: hosted
version: "0.3.2"
version: "0.3.1"
sqlite_async:
dependency: "direct main"
description:
name: sqlite_async
sha256: "4da3b035bcce83d10beae879c6539e75230826bba95ed440565b6afbdd8b1550"
sha256: "9332aedd311a19dd215dcb55729bc68dc587dc7655b569ab8819b68ee0be0082"
url: "https://pub.dev"
source: hosted
version: "0.11.8"
version: "0.11.7"
stack_trace:
dependency: transitive
description:
@@ -2605,10 +2565,10 @@ packages:
dependency: "direct main"
description:
name: synchronized
sha256: c254ade258ec8282947a0acbbc90b9575b4f19673533ee46f2f6e9b3aeefd7c0
sha256: "0669c70faae6270521ee4f05bffd2919892d42d1276e6c495be80174b6bc0ef6"
url: "https://pub.dev"
source: hosted
version: "3.4.0"
version: "3.3.1"
system_info_plus:
dependency: "direct main"
description:
@@ -2741,26 +2701,26 @@ packages:
dependency: "direct main"
description:
name: url_launcher
sha256: f6a7e5c4835bb4e3026a04793a4199ca2d14c739ec378fdfe23fc8075d0439f8
sha256: "9d06212b1362abc2f0f0d78e6f09f726608c74e3b9462e8368bb03314aa8d603"
url: "https://pub.dev"
source: hosted
version: "6.3.2"
version: "6.3.1"
url_launcher_android:
dependency: transitive
description:
name: url_launcher_android
sha256: "69ee86740f2847b9a4ba6cffa74ed12ce500bbe2b07f3dc1e643439da60637b7"
sha256: "8582d7f6fe14d2652b4c45c9b6c14c0b678c2af2d083a11b604caeba51930d79"
url: "https://pub.dev"
source: hosted
version: "6.3.18"
version: "6.3.16"
url_launcher_ios:
dependency: transitive
description:
name: url_launcher_ios
sha256: d80b3f567a617cb923546034cc94bfe44eb15f989fe670b37f26abdb9d939cb7
sha256: "7f2022359d4c099eea7df3fdf739f7d3d3b9faf3166fb1dd390775176e0b76cb"
url: "https://pub.dev"
source: hosted
version: "6.3.4"
version: "6.3.3"
url_launcher_linux:
dependency: transitive
description:
@@ -2773,10 +2733,10 @@ packages:
dependency: transitive
description:
name: url_launcher_macos
sha256: c043a77d6600ac9c38300567f33ef12b0ef4f4783a2c1f00231d2b1941fea13f
sha256: "17ba2000b847f334f16626a574c702b196723af2a289e7a93ffcb79acff855c2"
url: "https://pub.dev"
source: hosted
version: "3.2.3"
version: "3.2.2"
url_launcher_platform_interface:
dependency: transitive
description:
@@ -2829,10 +2789,10 @@ packages:
dependency: transitive
description:
name: vector_graphics_compiler
sha256: d354a7ec6931e6047785f4db12a1f61ec3d43b207fc0790f863818543f8ff0dc
sha256: "557a315b7d2a6dbb0aaaff84d857967ce6bdc96a63dc6ee2a57ce5a6ee5d3331"
url: "https://pub.dev"
source: hosted
version: "1.1.19"
version: "1.1.17"
vector_math:
dependency: transitive
description:
@@ -2879,18 +2839,18 @@ packages:
dependency: transitive
description:
name: video_player_android
sha256: "59e5a457ddcc1688f39e9aef0efb62aa845cf0cbbac47e44ac9730dc079a2385"
sha256: "4a5135754a62dbc827a64a42ef1f8ed72c962e191c97e2d48744225c2b9ebb73"
url: "https://pub.dev"
source: hosted
version: "2.8.13"
version: "2.8.7"
video_player_avfoundation:
dependency: transitive
description:
name: video_player_avfoundation
sha256: f9a780aac57802b2892f93787e5ea53b5f43cc57dc107bee9436458365be71cd
sha256: "0d47db6cbf72db61d86369219efd35c7f9d93515e1319da941ece81b1f21c49c"
url: "https://pub.dev"
source: hosted
version: "2.8.4"
version: "2.7.2"
video_player_platform_interface:
dependency: transitive
description:
@@ -2903,10 +2863,10 @@ packages:
dependency: transitive
description:
name: video_player_web
sha256: "9f3c00be2ef9b76a95d94ac5119fb843dca6f2c69e6c9968f6f2b6c9e7afbdeb"
sha256: e8bba2e5d1e159d5048c9a491bb2a7b29c535c612bb7d10c1e21107f5bd365ba
url: "https://pub.dev"
source: hosted
version: "2.4.0"
version: "2.3.5"
video_thumbnail:
dependency: "direct main"
description:
@@ -2959,10 +2919,10 @@ packages:
dependency: "direct overridden"
description:
name: watcher
sha256: "5bf046f41320ac97a469d506261797f35254fa61c641741ef32dacda98b7d39c"
sha256: "0b7fd4a0bbc4b92641dbf20adfd7e3fd1398fe17102d94b674234563e110088a"
url: "https://pub.dev"
source: hosted
version: "1.1.3"
version: "1.1.2"
web:
dependency: transitive
description:
@@ -3007,18 +2967,18 @@ packages:
dependency: "direct main"
description:
name: wechat_assets_picker
sha256: c307e50394c1e6dfcd5c4701e84efb549fce71444fedcf2e671c50d809b3e2a1
sha256: cafe3d32564ed3cacf9822f251941f7b44fe9885c17c8de4fca7e939a459e1ef
url: "https://pub.dev"
source: hosted
version: "9.8.0"
version: "9.5.1"
wechat_picker_library:
dependency: transitive
description:
name: wechat_picker_library
sha256: "5cb61b9aa935b60da5b043f8446fbb9c5077419f20ccc4856bf444aec4f44bc1"
sha256: a42e09cb85b15fc9410f6a69671371cc60aa99c4a1f7967f6593a7f665f6f47a
url: "https://pub.dev"
source: hosted
version: "1.0.7"
version: "1.0.5"
widgets_to_image:
dependency: "direct main"
description:
@@ -3071,10 +3031,10 @@ packages:
dependency: "direct main"
description:
name: xml
sha256: "971043b3a0d3da28727e40ed3e0b5d18b742fa5a68665cca88e74b7876d5e025"
sha256: b015a8ad1c488f66851d762d3090a21c600e479dc75e68328c52774040cf9226
url: "https://pub.dev"
source: hosted
version: "6.6.1"
version: "6.5.0"
xmlstream:
dependency: transitive
description:

View File

@@ -77,6 +77,8 @@ dependencies:
ref: remove-event-sub
figma_squircle: ^0.6.3
file_saver: ^0.2.14
firebase_core: ^3.6.0
firebase_messaging: ^15.1.3
fixnum: ^1.1.1
flutter:
sdk: flutter
@@ -113,8 +115,7 @@ dependencies:
html_unescape: ^2.0.0
http: ^1.1.0
image: ^4.0.17
image_editor: ^1.6.0
image_picker: ^1.1.1
in_app_purchase: ^3.0.7
intl: ^0.19.0
latlong2: ^0.9.0
launcher_icon_switcher: ^0.0.2

View File

@@ -1,19 +0,0 @@
Copyright (c) 2017 Transistor Software <info@transistorsoft.com>
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.

View File

@@ -1,22 +0,0 @@
Transistor Background Fetch
===========================================================================
Copyright (c) 2017 Transistor Software <info@transistorsoft.com>
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.

View File

@@ -1,28 +0,0 @@
#
# Be sure to run `pod lib lint TSBackgroundFetch.podspec' to ensure this is a
# valid spec before submitting.
#
# Any lines starting with a # are optional, but their use is encouraged
# To learn more about a Podspec see http://guides.cocoapods.org/syntax/podspec.html
#
Pod::Spec.new do |s|
s.name = 'TSBackgroundFetch'
s.version = '0.0.1'
s.summary = 'iOS Background Fetch API Manager'
s.description = <<-DESC
iOS Background Fetch API Manager with ability to handle multiple listeners.
DESC
s.homepage = 'http://www.transistorsoft.com'
s.license = { :type => 'MIT', :file => 'LICENSE' }
s.author = { 'christocracy' => 'christocracy@gmail.com' }
s.source = { :git => 'https://github.com/transistorsoft/transistor-background-fetch.git', :tag => s.version.to_s }
s.social_media_url = 'https://twitter.com/christocracy'
s.ios.deployment_target = '8.0'
s.source_files = 'ios/TSBackgroundFetch/TSBackgroundFetch/*.{h,m}'
s.vendored_frameworks = 'ios/TSBackgroundFetch/TSBackgroundFetch.framework'
end

View File

@@ -1,9 +0,0 @@
*.iml
.gradle
/local.properties
/.idea/workspace.xml
/.idea/libraries
.DS_Store
/build
/captures
.externalNativeBuild

View File

@@ -1,28 +0,0 @@
apply plugin: 'com.android.application'
android {
compileSdkVersion 30
defaultConfig {
applicationId "com.transistorsoft.backgroundfetch"
minSdkVersion 16
targetSdkVersion 30
versionCode 1
versionName "1.0"
testInstrumentationRunner "android.support.test.runner.AndroidJUnitRunner"
}
buildTypes {
release {
minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
}
}
}
dependencies {
testImplementation 'junit:junit:4.12'
androidTestImplementation 'androidx.test:runner:1.2.0'
androidTestImplementation 'androidx.test.espresso:espresso-core:3.2.0'
implementation fileTree(dir: 'libs', include: ['*.jar'])
}

View File

@@ -1,21 +0,0 @@
# Add project specific ProGuard rules here.
# You can control the set of applied configuration files using the
# proguardFiles setting in build.gradle.
#
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html
# If your project uses WebView with JS, uncomment the following
# and specify the fully qualified class name to the JavaScript interface
# class:
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
# public *;
#}
# Uncomment this to preserve the line number information for
# debugging stack traces.
#-keepattributes SourceFile,LineNumberTable
# If you keep the line number information, uncomment this to
# hide the original source file name.
#-renamesourcefileattribute SourceFile

View File

@@ -1,27 +0,0 @@
package com.transistorsoft.backgroundfetch;
import android.content.Context;
import androidx.test.InstrumentationRegistry;
import androidx.test.runner.AndroidJUnit4;
import org.junit.Test;
import org.junit.runner.RunWith;
import static org.junit.Assert.*;
/**
* Instrumented test, which will execute on an Android device.
*
* @see <a href="http://d.android.com/tools/testing">Testing documentation</a>
*/
@RunWith(AndroidJUnit4.class)
public class ExampleInstrumentedTest {
@Test
public void useAppContext() throws Exception {
// Context of the app under test.
Context appContext = InstrumentationRegistry.getTargetContext();
assertEquals("com.transistorsoft.backgroundfetch", appContext.getPackageName());
}
}

View File

@@ -1,11 +0,0 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="com.transistorsoft.backgroundfetch">
<application
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/AppTheme" />
</manifest>

View File

@@ -1,34 +0,0 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt"
android:width="108dp"
android:height="108dp"
android:viewportHeight="108"
android:viewportWidth="108">
<path
android:fillType="evenOdd"
android:pathData="M32,64C32,64 38.39,52.99 44.13,50.95C51.37,48.37 70.14,49.57 70.14,49.57L108.26,87.69L108,109.01L75.97,107.97L32,64Z"
android:strokeColor="#00000000"
android:strokeWidth="1">
<aapt:attr name="android:fillColor">
<gradient
android:endX="78.5885"
android:endY="90.9159"
android:startX="48.7653"
android:startY="61.0927"
android:type="linear">
<item
android:color="#44000000"
android:offset="0.0" />
<item
android:color="#00000000"
android:offset="1.0" />
</gradient>
</aapt:attr>
</path>
<path
android:fillColor="#FFFFFF"
android:fillType="nonZero"
android:pathData="M66.94,46.02L66.94,46.02C72.44,50.07 76,56.61 76,64L32,64C32,56.61 35.56,50.11 40.98,46.06L36.18,41.19C35.45,40.45 35.45,39.3 36.18,38.56C36.91,37.81 38.05,37.81 38.78,38.56L44.25,44.05C47.18,42.57 50.48,41.71 54,41.71C57.48,41.71 60.78,42.57 63.68,44.05L69.11,38.56C69.84,37.81 70.98,37.81 71.71,38.56C72.44,39.3 72.44,40.45 71.71,41.19L66.94,46.02ZM62.94,56.92C64.08,56.92 65,56.01 65,54.88C65,53.76 64.08,52.85 62.94,52.85C61.8,52.85 60.88,53.76 60.88,54.88C60.88,56.01 61.8,56.92 62.94,56.92ZM45.06,56.92C46.2,56.92 47.13,56.01 47.13,54.88C47.13,53.76 46.2,52.85 45.06,52.85C43.92,52.85 43,53.76 43,54.88C43,56.01 43.92,56.92 45.06,56.92Z"
android:strokeColor="#00000000"
android:strokeWidth="1" />
</vector>

View File

@@ -1,170 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportHeight="108"
android:viewportWidth="108">
<path
android:fillColor="#26A69A"
android:pathData="M0,0h108v108h-108z" />
<path
android:fillColor="#00000000"
android:pathData="M9,0L9,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M19,0L19,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M29,0L29,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M39,0L39,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M49,0L49,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M59,0L59,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M69,0L69,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M79,0L79,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M89,0L89,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M99,0L99,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,9L108,9"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,19L108,19"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,29L108,29"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,39L108,39"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,49L108,49"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,59L108,59"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,69L108,69"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,79L108,79"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,89L108,89"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,99L108,99"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M19,29L89,29"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M19,39L89,39"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M19,49L89,49"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M19,59L89,59"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M19,69L89,69"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M19,79L89,79"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M29,19L29,89"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M39,19L39,89"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M49,19L49,89"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M59,19L59,89"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M69,19L69,89"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M79,19L79,89"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
</vector>

View File

@@ -1,5 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>

View File

@@ -1,5 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

View File

@@ -1,6 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="colorPrimary">#3F51B5</color>
<color name="colorPrimaryDark">#303F9F</color>
<color name="colorAccent">#FF4081</color>
</resources>

View File

@@ -1,3 +0,0 @@
<resources>
<string name="app_name">BackgroundFetch</string>
</resources>

View File

@@ -1,11 +0,0 @@
<resources>
<!-- Base application theme. -->
<style name="AppTheme" parent="Theme.AppCompat.Light.DarkActionBar">
<!-- Customize your theme here. -->
<item name="colorPrimary">@color/colorPrimary</item>
<item name="colorPrimaryDark">@color/colorPrimaryDark</item>
<item name="colorAccent">@color/colorAccent</item>
</style>
</resources>

View File

@@ -1,17 +0,0 @@
package com.transistorsoft.backgroundfetch;
import org.junit.Test;
import static org.junit.Assert.*;
/**
* Example local unit test, which will execute on the development machine (host).
*
* @see <a href="http://d.android.com/tools/testing">Testing documentation</a>
*/
public class ExampleUnitTest {
@Test
public void addition_isCorrect() throws Exception {
assertEquals(4, 2 + 2);
}
}

View File

@@ -1,34 +0,0 @@
// Top-level build file where you can add configuration options common to all sub-projects/modules.
buildscript {
repositories {
google()
mavenCentral()
}
dependencies {
classpath 'com.android.tools.build:gradle:4.1.3'
// NOTE: Do not place your application dependencies here; they belong
// in the individual module build.gradle files
}
}
allprojects {
repositories {
google()
mavenCentral()
}
}
task clean(type: Delete) {
delete rootProject.buildDir
}
ext {
compileSdkVersion = 32
targetSdkVersion = 31
buildToolsVersion = "29.0.6"
appCompatVersion = "1.4.1"
}

View File

@@ -1,23 +0,0 @@
# Project-wide Gradle settings.
# IDE (e.g. Android Studio) users:
# Gradle settings configured through the IDE *will override*
# any settings specified in this file.
# For more details on how to configure your build environment visit
# http://www.gradle.org/docs/current/userguide/build_environment.html
# Specifies the JVM arguments used for the daemon process.
# The setting is particularly useful for tweaking memory settings.
org.gradle.jvmargs=-Xmx1536m
# When configured, Gradle will run in incubating parallel mode.
# This option should only be used with decoupled projects. More details, visit
# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects
# org.gradle.parallel=true
VERSION_NAME=0.5.6
VERSION_CODE=21
android.useAndroidX=true
android.enableJetifier=true

View File

@@ -1,6 +0,0 @@
#Thu Jul 15 09:21:17 EDT 2021
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-6.5-bin.zip

View File

@@ -1,160 +0,0 @@
#!/usr/bin/env bash
##############################################################################
##
## Gradle start up script for UN*X
##
##############################################################################
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS=""
APP_NAME="Gradle"
APP_BASE_NAME=`basename "$0"`
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD="maximum"
warn ( ) {
echo "$*"
}
die ( ) {
echo
echo "$*"
echo
exit 1
}
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
case "`uname`" in
CYGWIN* )
cygwin=true
;;
Darwin* )
darwin=true
;;
MINGW* )
msys=true
;;
esac
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
PRG="$0"
# Need this for relative symlinks.
while [ -h "$PRG" ] ; do
ls=`ls -ld "$PRG"`
link=`expr "$ls" : '.*-> \(.*\)$'`
if expr "$link" : '/.*' > /dev/null; then
PRG="$link"
else
PRG=`dirname "$PRG"`"/$link"
fi
done
SAVED="`pwd`"
cd "`dirname \"$PRG\"`/" >/dev/null
APP_HOME="`pwd -P`"
cd "$SAVED" >/dev/null
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD="$JAVA_HOME/jre/sh/java"
else
JAVACMD="$JAVA_HOME/bin/java"
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD="java"
which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
# Increase the maximum file descriptors if we can.
if [ "$cygwin" = "false" -a "$darwin" = "false" ] ; then
MAX_FD_LIMIT=`ulimit -H -n`
if [ $? -eq 0 ] ; then
if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then
MAX_FD="$MAX_FD_LIMIT"
fi
ulimit -n $MAX_FD
if [ $? -ne 0 ] ; then
warn "Could not set maximum file descriptor limit: $MAX_FD"
fi
else
warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT"
fi
fi
# For Darwin, add options to specify how the application appears in the dock
if $darwin; then
GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\""
fi
# For Cygwin, switch paths to Windows format before running java
if $cygwin ; then
APP_HOME=`cygpath --path --mixed "$APP_HOME"`
CLASSPATH=`cygpath --path --mixed "$CLASSPATH"`
JAVACMD=`cygpath --unix "$JAVACMD"`
# We build the pattern for arguments to be converted via cygpath
ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null`
SEP=""
for dir in $ROOTDIRSRAW ; do
ROOTDIRS="$ROOTDIRS$SEP$dir"
SEP="|"
done
OURCYGPATTERN="(^($ROOTDIRS))"
# Add a user-defined pattern to the cygpath arguments
if [ "$GRADLE_CYGPATTERN" != "" ] ; then
OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)"
fi
# Now convert the arguments - kludge to limit ourselves to /bin/sh
i=0
for arg in "$@" ; do
CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -`
CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option
if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition
eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"`
else
eval `echo args$i`="\"$arg\""
fi
i=$((i+1))
done
case $i in
(0) set -- ;;
(1) set -- "$args0" ;;
(2) set -- "$args0" "$args1" ;;
(3) set -- "$args0" "$args1" "$args2" ;;
(4) set -- "$args0" "$args1" "$args2" "$args3" ;;
(5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;;
(6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;;
(7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;;
(8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;;
(9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;;
esac
fi
# Split up the JVM_OPTS And GRADLE_OPTS values into an array, following the shell quoting and substitution rules
function splitJvmOpts() {
JVM_OPTS=("$@")
}
eval splitJvmOpts $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS
JVM_OPTS[${#JVM_OPTS[*]}]="-Dorg.gradle.appname=$APP_BASE_NAME"
exec "$JAVACMD" "${JVM_OPTS[@]}" -classpath "$CLASSPATH" org.gradle.wrapper.GradleWrapperMain "$@"

View File

@@ -1,90 +0,0 @@
@if "%DEBUG%" == "" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS=
set DIRNAME=%~dp0
if "%DIRNAME%" == "" set DIRNAME=.
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if "%ERRORLEVEL%" == "0" goto init
echo.
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
echo.
echo Please set the JAVA_HOME variable in your environment to match the
echo location of your Java installation.
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto init
echo.
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
echo.
echo Please set the JAVA_HOME variable in your environment to match the
echo location of your Java installation.
goto fail
:init
@rem Get command-line arguments, handling Windowz variants
if not "%OS%" == "Windows_NT" goto win9xME_args
if "%@eval[2+2]" == "4" goto 4NT_args
:win9xME_args
@rem Slurp the command line arguments.
set CMD_LINE_ARGS=
set _SKIP=2
:win9xME_args_slurp
if "x%~1" == "x" goto execute
set CMD_LINE_ARGS=%*
goto execute
:4NT_args
@rem Get arguments from the 4NT Shell from JP Software
set CMD_LINE_ARGS=%$
:execute
@rem Setup the command line
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS%
:end
@rem End local scope for the variables with windows NT shell
if "%ERRORLEVEL%"=="0" goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
exit /b 1
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega

View File

@@ -1 +0,0 @@
include ':app', ':tsbackgroundfetch'

View File

@@ -1,152 +0,0 @@
apply plugin: 'com.android.library'
apply plugin: 'maven'
apply plugin: 'maven-publish'
android {
compileSdkVersion rootProject.compileSdkVersion
defaultConfig {
minSdkVersion 16
targetSdkVersion rootProject.targetSdkVersion
versionCode 1
versionName "1.0"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
}
buildTypes {
release {
minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
}
}
publishing {
publications {
tslocationmanager(MavenPublication) {
groupId 'com.transistorsoft'
artifactId 'tsbackgroundfetch'
version VERSION_NAME
artifact("$buildDir/outputs/aar/tsbackgroundfetch-release.aar")
}
}
repositories {
mavenLocal()
}
}
compileOptions {
sourceCompatibility JavaVersion.VERSION_1_8
targetCompatibility JavaVersion.VERSION_1_8
}
}
dependencies {
testImplementation 'junit:junit:4.12'
androidTestImplementation 'androidx.test:runner:1.2.0'
implementation fileTree(dir: 'libs', include: ['*.jar'])
implementation "androidx.lifecycle:lifecycle-runtime:2.5.1"
implementation "androidx.lifecycle:lifecycle-extensions:2.2.0"
//implementation "androidx.appcompat:appcompat:$rootProject.appCompatVersion"
}
// Build Release
task buildRelease { task ->
task.dependsOn 'flutterRelease'
}
// Publish Release.
task publishRelease { task ->
task.dependsOn 'assembleRelease'
}
tasks["publishRelease"].mustRunAfter("assembleRelease")
tasks["publishRelease"].finalizedBy("publish")
def WORKSPACE_PATH = "/Users/chris/workspace"
// Build local maven repo.
def LIBRARY_PATH = "com/transistorsoft/tsbackgroundfetch"
task buildLocalRepository { task ->
task.dependsOn 'publishRelease'
doLast {
delete "$buildDir/repo-local"
copy {
from "$buildDir/repo/$LIBRARY_PATH/$VERSION_NAME"
into "$buildDir/repo-local/$LIBRARY_PATH/$VERSION_NAME"
}
copy {
from("$buildDir/repo/$LIBRARY_PATH/maven-metadata.xml")
into("$buildDir/repo-local/$LIBRARY_PATH")
}
}
}
def cordovaDir = "$WORKSPACE_PATH/background-geolocation/cordova/cordova-plugin-background-fetch"
task cordovaRelease { task ->
task.dependsOn 'buildLocalRepository'
doLast {
delete "$cordovaDir/src/android/libs"
copy {
// Maven repo format.
from("$buildDir/repo-local")
into("$cordovaDir/src/android/libs")
// OLD FORMAT
//from("$buildDir/outputs/aar/tsbackgroundfetch-release.aar")
//into("$cordovaDir/src/android/libs/tsbackgroundfetch")
//rename(/(.*)-release/, '$1-' + VERSION_NAME)
}
}
}
def reactNativeDir = "$WORKSPACE_PATH/background-geolocation/react/react-native-background-fetch"
task reactNativeRelease { task ->
task.dependsOn 'buildLocalRepository'
doLast {
delete "$reactNativeDir/android/libs"
copy {
// Maven repo format.
from("$buildDir/repo-local")
into("$reactNativeDir/android/libs")
// OLD format.
//from("$buildDir/outputs/aar/tsbackgroundfetch-release.aar")
//into("$reactNativeDir/android/libs")
//rename(/(.*)-release/, '$1-' + VERSION_NAME)
}
}
}
def flutterDir = "$WORKSPACE_PATH/background-geolocation/flutter/flutter_background_fetch"
task flutterRelease { task ->
task.dependsOn 'buildLocalRepository'
doLast {
delete "$flutterDir/android/libs"
copy {
// Maven repo format.
from("$buildDir/repo-local")
into("$flutterDir/android/libs")
// OLD format.
//from("$buildDir/outputs/aar/tsbackgroundfetch-release.aar")
//into("$flutterDir/android/libs")
//rename(/(.*)-release/, '$1-' + VERSION_NAME)
}
}
}
def capacitorDir = "$WORKSPACE_PATH/background-geolocation/capacitor/capacitor-background-fetch"
task capacitorRelease { task ->
task.dependsOn 'buildLocalRepository'
doLast {
delete "$capacitorDir/android/libs"
copy {
// Maven repo format.
from("$buildDir/repo-local")
into("$capacitorDir/android/libs")
}
}
}
task nativeScriptRelease(type: Copy) {
from('./build/outputs/aar/tsbackgroundfetch-release.aar')
into("$WORKSPACE_PATH/NativeScript/background-geolocation/nativescript-background-fetch/src/platforms/android/libs")
rename('tsbackgroundfetch-release.aar', 'tsbackgroundfetch.aar')
}

View File

@@ -1,21 +0,0 @@
# Add project specific ProGuard rules here.
# You can control the set of applied configuration files using the
# proguardFiles setting in build.gradle.
#
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html
# If your project uses WebView with JS, uncomment the following
# and specify the fully qualified class name to the JavaScript interface
# class:
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
# public *;
#}
# Uncomment this to preserve the line number information for
# debugging stack traces.
#-keepattributes SourceFile,LineNumberTable
# If you keep the line number information, uncomment this to
# hide the original source file name.
#-renamesourcefileattribute SourceFile

View File

@@ -1,26 +0,0 @@
package com.transistorsoft.tsbackgroundfetch;
import android.content.Context;
import android.support.test.InstrumentationRegistry;
import android.support.test.runner.AndroidJUnit4;
import org.junit.Test;
import org.junit.runner.RunWith;
import static org.junit.Assert.*;
/**
* Instrumented test, which will execute on an Android device.
*
* @see <a href="http://d.android.com/tools/testing">Testing documentation</a>
*/
@RunWith(AndroidJUnit4.class)
public class ExampleInstrumentedTest {
@Test
public void useAppContext() throws Exception {
// Context of the app under test.
Context appContext = InstrumentationRegistry.getTargetContext();
assertEquals("com.transistorsoft.tsbackgroundfetch.test", appContext.getPackageName());
}
}

View File

@@ -1,18 +0,0 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="com.transistorsoft.tsbackgroundfetch">
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<uses-permission android:name="android.permission.SCHEDULE_EXACT_ALARM" />
<application>
<receiver android:name="com.transistorsoft.tsbackgroundfetch.FetchAlarmReceiver" />
<service android:name="com.transistorsoft.tsbackgroundfetch.FetchJobService" android:permission="android.permission.BIND_JOB_SERVICE" android:exported="true" />
<receiver android:name="com.transistorsoft.tsbackgroundfetch.BootReceiver" android:exported="false">
<intent-filter>
<action android:name="android.intent.action.BOOT_COMPLETED" />
<action android:name="android.intent.action.MY_PACKAGE_REPLACED" />
</intent-filter>
</receiver>
</application>
</manifest>

View File

@@ -1,291 +0,0 @@
package com.transistorsoft.tsbackgroundfetch;
import android.app.AlarmManager;
import android.app.PendingIntent;
import android.app.job.JobInfo;
import android.app.job.JobScheduler;
import android.content.ComponentName;
import android.content.Context;
import android.content.Intent;
import android.os.Build;
import android.os.PersistableBundle;
import android.util.Log;
import org.json.JSONException;
import org.json.JSONObject;
import java.lang.reflect.Constructor;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.TimeUnit;
public class BGTask {
static int MAX_TIME = 60000;
private static final List<BGTask> mTasks = new ArrayList<>();
static BGTask getTask(String taskId) {
synchronized (mTasks) {
for (BGTask task : mTasks) {
if (task.hasTaskId(taskId)) return task;
}
}
return null;
}
static void addTask(BGTask task) {
synchronized (mTasks) {
mTasks.add(task);
}
}
static void removeTask(String taskId) {
synchronized (mTasks) {
BGTask found = null;
for (BGTask task : mTasks) {
if (task.hasTaskId(taskId)) {
found = task;
break;
}
}
if (found != null) {
mTasks.remove(found);
}
}
}
static void clear() {
synchronized (mTasks) {
mTasks.clear();
}
}
private FetchJobService.CompletionHandler mCompletionHandler;
private String mTaskId;
private int mJobId;
private Runnable mTimeoutTask;
private boolean mTimedout = false;
BGTask(final Context context, String taskId, FetchJobService.CompletionHandler handler, int jobId) {
mTaskId = taskId;
mCompletionHandler = handler;
mJobId = jobId;
mTimeoutTask = new Runnable() {
@Override public void run() {
onTimeout(context);
}
};
BackgroundFetch.getUiHandler().postDelayed(mTimeoutTask, MAX_TIME);
}
public boolean getTimedOut() {
return mTimedout;
}
public String getTaskId() { return mTaskId; }
int getJobId() { return mJobId; }
boolean hasTaskId(String taskId) {
return ((mTaskId != null) && mTaskId.equalsIgnoreCase(taskId));
}
void setCompletionHandler(FetchJobService.CompletionHandler handler) {
mCompletionHandler = handler;
}
void finish() {
if (mCompletionHandler != null) {
mCompletionHandler.finish();
}
if (mTimeoutTask != null) {
BackgroundFetch.getUiHandler().removeCallbacks(mTimeoutTask);
}
mCompletionHandler = null;
removeTask(mTaskId);
}
static void reschedule(Context context, BackgroundFetchConfig existing, BackgroundFetchConfig config) {
BGTask existingTask = BGTask.getTask(existing.getTaskId());
if (existingTask != null) {
existingTask.finish();
}
cancel(context, existing.getTaskId(), existing.getJobId());
schedule(context, config);
}
static void schedule(Context context, BackgroundFetchConfig config) {
Log.d(BackgroundFetch.TAG, config.toString());
long interval = (config.isFetchTask()) ? (TimeUnit.MINUTES.toMillis(config.getMinimumFetchInterval())) : config.getDelay();
if (android.os.Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP && !config.getForceAlarmManager()) {
// API 21+ uses new JobScheduler API
JobScheduler jobScheduler = (JobScheduler) context.getSystemService(Context.JOB_SCHEDULER_SERVICE);
JobInfo.Builder builder = new JobInfo.Builder(config.getJobId(), new ComponentName(context, FetchJobService.class))
.setRequiredNetworkType(config.getRequiredNetworkType())
.setRequiresDeviceIdle(config.getRequiresDeviceIdle())
.setRequiresCharging(config.getRequiresCharging())
.setPersisted(config.getStartOnBoot() && !config.getStopOnTerminate());
if (config.getPeriodic()) {
if (android.os.Build.VERSION.SDK_INT >= 24) {
builder.setPeriodic(interval, interval);
} else {
builder.setPeriodic(interval);
}
} else {
builder.setMinimumLatency(interval);
}
PersistableBundle extras = new PersistableBundle();
extras.putString(BackgroundFetchConfig.FIELD_TASK_ID, config.getTaskId());
extras.putLong("scheduled_at", System.currentTimeMillis());
builder.setExtras(extras);
if (android.os.Build.VERSION.SDK_INT >= 26) {
builder.setRequiresStorageNotLow(config.getRequiresStorageNotLow());
builder.setRequiresBatteryNotLow(config.getRequiresBatteryNotLow());
}
if (jobScheduler != null) {
jobScheduler.schedule(builder.build());
}
} else {
// Everyone else get AlarmManager
AlarmManager alarmManager = (AlarmManager) context.getSystemService(Context.ALARM_SERVICE);
if (alarmManager != null) {
PendingIntent pi = getAlarmPI(context, config.getTaskId());
long delay = System.currentTimeMillis() + interval;
if (config.getPeriodic()) {
alarmManager.setRepeating(AlarmManager.RTC_WAKEUP, delay, interval, pi);
} else {
if (android.os.Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
alarmManager.setExactAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, delay, pi);
} else if (android.os.Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) {
alarmManager.setExact(AlarmManager.RTC_WAKEUP, delay, pi);
} else {
alarmManager.set(AlarmManager.RTC_WAKEUP, delay, pi);
}
}
}
}
}
void onTimeout(Context context) {
mTimedout = true;
Log.d(BackgroundFetch.TAG, "[BGTask] timeout: " + mTaskId);
BackgroundFetch adapter = BackgroundFetch.getInstance(context);
if (!LifecycleManager.getInstance().isHeadless()) {
BackgroundFetch.Callback callback = adapter.getFetchCallback();
if (callback != null) {
callback.onTimeout(mTaskId);
}
} else {
BackgroundFetchConfig config = adapter.getConfig(mTaskId);
if (config != null) {
if (config.getJobService() != null) {
fireHeadlessEvent(context, config);
} else {
adapter.finish(mTaskId);
}
} else {
Log.e(BackgroundFetch.TAG, "[BGTask] failed to load config for taskId: " + mTaskId);
adapter.finish(mTaskId);
}
}
}
// Fire a headless background-fetch event by reflecting an instance of Config.jobServiceClass.
// Will attempt to reflect upon two different forms of Headless class:
// 1: new HeadlessTask(context, taskId)
// or
// 2: new HeadlessTask().onFetch(context, taskId);
//
void fireHeadlessEvent(Context context, BackgroundFetchConfig config) throws Error {
try {
// Get class via reflection.
Class<?> HeadlessClass = Class.forName(config.getJobService());
Class[] types = { Context.class, BGTask.class };
Object[] params = { context, this};
try {
// 1: new HeadlessTask(context, taskId);
Constructor<?> constructor = HeadlessClass.getDeclaredConstructor(types);
constructor.newInstance(params);
} catch (NoSuchMethodException e) {
// 2: new HeadlessTask().onFetch(context, taskId);
Constructor<?> constructor = HeadlessClass.getConstructor();
Object instance = constructor.newInstance();
Method onFetch = instance.getClass().getDeclaredMethod("onFetch", types);
onFetch.invoke(instance, params);
}
} catch (ClassNotFoundException e) {
throw new Error(e.getMessage());
} catch (NoSuchMethodException e) {
throw new Error(e.getMessage());
} catch (IllegalAccessException e) {
throw new Error(e.getMessage());
} catch (InstantiationException e) {
throw new Error(e.getMessage());
} catch (InvocationTargetException e) {
throw new Error(e.getMessage());
}
}
static void cancel(Context context, String taskId, int jobId) {
Log.i(BackgroundFetch.TAG, "- cancel taskId=" + taskId + ", jobId=" + jobId);
if (android.os.Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP && (jobId != 0)) {
JobScheduler jobScheduler = (JobScheduler) context.getSystemService(Context.JOB_SCHEDULER_SERVICE);
if (jobScheduler != null) {
jobScheduler.cancel(jobId);
}
} else {
AlarmManager alarmManager = (AlarmManager) context.getSystemService(Context.ALARM_SERVICE);
if (alarmManager != null) {
alarmManager.cancel(BGTask.getAlarmPI(context, taskId));
}
}
}
static PendingIntent getAlarmPI(Context context, String taskId) {
Intent intent = new Intent(context, FetchAlarmReceiver.class);
intent.setAction(taskId);
return PendingIntent.getBroadcast(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT|PendingIntent.FLAG_IMMUTABLE);
}
public String toString() {
return "[BGTask taskId=" + mTaskId + "]";
}
public Map<String, Object> toMap() {
Map<String, Object> map = new HashMap<>();
map.put("taskId", mTaskId);
map.put("timeout", mTimedout);
return map;
}
public JSONObject toJson() {
JSONObject json = new JSONObject();
try {
json.put("taskId", mTaskId);
json.put("timeout", mTimedout);
} catch (JSONException e) {
e.printStackTrace();
}
return json;
}
static class Error extends RuntimeException {
public Error(String msg) {
super(msg);
}
}
}

View File

@@ -1,300 +0,0 @@
package com.transistorsoft.tsbackgroundfetch;
import android.annotation.TargetApi;
import android.app.ActivityManager;
import android.content.Context;
import android.content.Intent;
import android.content.pm.PackageManager;
import android.os.Build;
import android.os.Handler;
import android.os.Looper;
import android.util.Log;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
/**
* Created by chris on 2018-01-11.
*/
public class BackgroundFetch {
public static final String TAG = "TSBackgroundFetch";
public static final String ACTION_CONFIGURE = "configure";
public static final String ACTION_START = "start";
public static final String ACTION_STOP = "stop";
public static final String ACTION_FINISH = "finish";
public static final String ACTION_STATUS = "status";
public static final String ACTION_FORCE_RELOAD = TAG + "-forceReload";
public static final String EVENT_FETCH = ".event.BACKGROUND_FETCH";
public static final int STATUS_AVAILABLE = 2;
private static BackgroundFetch mInstance = null;
private static ExecutorService sThreadPool;
private static Handler uiHandler;
@SuppressWarnings({"WeakerAccess"})
public static Handler getUiHandler() {
if (uiHandler == null) {
uiHandler = new Handler(Looper.getMainLooper());
}
return uiHandler;
}
@SuppressWarnings({"WeakerAccess"})
public static ExecutorService getThreadPool() {
if (sThreadPool == null) {
sThreadPool = Executors.newCachedThreadPool();
}
return sThreadPool;
}
@SuppressWarnings({"WeakerAccess"})
public static BackgroundFetch getInstance(Context context) {
if (mInstance == null) {
mInstance = getInstanceSynchronized(context.getApplicationContext());
}
return mInstance;
}
private static synchronized BackgroundFetch getInstanceSynchronized(Context context) {
if (mInstance == null) mInstance = new BackgroundFetch(context.getApplicationContext());
return mInstance;
}
private Context mContext;
private BackgroundFetch.Callback mFetchCallback;
private final Map<String, BackgroundFetchConfig> mConfig = new HashMap<>();
private BackgroundFetch(Context context) {
mContext = context;
// Start Lifecycle Observer to be notified when app enters background.
getUiHandler().post(LifecycleManager.getInstance());
}
@SuppressWarnings({"unused"})
public void configure(BackgroundFetchConfig config, BackgroundFetch.Callback callback) {
Log.d(TAG, "- " + ACTION_CONFIGURE);
mFetchCallback = callback;
synchronized (mConfig) {
if (mConfig.containsKey(config.getTaskId())) {
// Developer called `.configure` again. Re-configure the plugin by re-scheduling the fetch task.
BackgroundFetchConfig existing = mConfig.get(config.getTaskId());
Log.d(TAG, "Re-configured existing task");
BGTask.reschedule(mContext, existing, config);
mConfig.put(config.getTaskId(), config);
return;
} else {
mConfig.put(config.getTaskId(), config);
}
}
start(config.getTaskId());
}
void onBoot() {
BackgroundFetchConfig.load(mContext, new BackgroundFetchConfig.OnLoadCallback() {
@Override public void onLoad(List<BackgroundFetchConfig> result) {
for (BackgroundFetchConfig config : result) {
if (!config.getStartOnBoot() || config.getStopOnTerminate()) {
config.destroy(mContext);
continue;
}
synchronized (mConfig) {
mConfig.put(config.getTaskId(), config);
}
if ((android.os.Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP_MR1) || config.getForceAlarmManager()) {
if (config.isFetchTask()) {
start(config.getTaskId());
} else {
scheduleTask(config);
}
}
}
}
});
}
@SuppressWarnings({"WeakerAccess"})
@TargetApi(21)
public void start(String fetchTaskId) {
Log.d(TAG, "- " + ACTION_START);
BGTask task = BGTask.getTask(fetchTaskId);
if (task != null) {
Log.e(TAG, "[" + TAG + " start] Task " + fetchTaskId + " already registered");
return;
}
registerTask(fetchTaskId);
}
@SuppressWarnings({"WeakerAccess"})
public void stop(String taskId) {
String msg = "- " + ACTION_STOP;
if (taskId != null) {
msg += ": " + taskId;
}
Log.d(TAG, msg);
if (taskId == null) {
synchronized (mConfig) {
for (BackgroundFetchConfig config : mConfig.values()) {
BGTask task = BGTask.getTask(config.getTaskId());
if (task != null) {
task.finish();
BGTask.removeTask(config.getTaskId());
}
BGTask.cancel(mContext, config.getTaskId(), config.getJobId());
config.destroy(mContext);
}
BGTask.clear();
}
} else {
BGTask task = BGTask.getTask(taskId);
if (task != null) {
task.finish();
BGTask.removeTask(task.getTaskId());
}
BackgroundFetchConfig config = getConfig(taskId);
if (config != null) {
config.destroy(mContext);
BGTask.cancel(mContext, config.getTaskId(), config.getJobId());
}
}
}
@SuppressWarnings({"WeakerAccess"})
public void scheduleTask(BackgroundFetchConfig config) {
synchronized (mConfig) {
if (mConfig.containsKey(config.getTaskId())) {
// This BackgroundFetchConfig already exists? Should we halt any existing Job/Alarm here?
}
config.save(mContext);
mConfig.put(config.getTaskId(), config);
}
String taskId = config.getTaskId();
registerTask(taskId);
}
@SuppressWarnings({"WeakerAccess"})
public void finish(String taskId) {
Log.d(TAG, "- " + ACTION_FINISH + ": " + taskId);
BGTask task = BGTask.getTask(taskId);
if (task != null) {
task.finish();
}
BackgroundFetchConfig config = getConfig(taskId);
if ((config != null) && !config.getPeriodic()) {
config.destroy(mContext);
synchronized (mConfig) {
mConfig.remove(taskId);
}
}
}
public int status() {
return STATUS_AVAILABLE;
}
BackgroundFetch.Callback getFetchCallback() {
return mFetchCallback;
}
void onFetch(final BGTask task) {
BGTask.addTask(task);
Log.d(TAG, "- Background Fetch event received: " + task.getTaskId());
synchronized (mConfig) {
if (mConfig.isEmpty()) {
BackgroundFetchConfig.load(mContext, new BackgroundFetchConfig.OnLoadCallback() {
@Override
public void onLoad(List<BackgroundFetchConfig> result) {
synchronized (mConfig) {
for (BackgroundFetchConfig config : result) {
mConfig.put(config.getTaskId(), config);
}
}
doFetch(task);
}
});
return;
}
}
doFetch(task);
}
private void registerTask(String taskId) {
BackgroundFetchConfig config = getConfig(taskId);
if (config == null) {
Log.e(TAG, "- registerTask failed to find BackgroundFetchConfig for taskId " + taskId);
return;
}
config.save(mContext);
String msg = "- registerTask: " + taskId;
if (!config.getForceAlarmManager()) {
msg += " (jobId: " + config.getJobId() + ")";
}
Log.d(TAG, msg);
BGTask.schedule(mContext, config);
}
private void doFetch(BGTask task) {
BackgroundFetchConfig config = getConfig(task.getTaskId());
if (config == null) {
BGTask.cancel(mContext, task.getTaskId(), task.getJobId());
return;
}
if (!LifecycleManager.getInstance().isHeadless()) {
if (mFetchCallback != null) {
mFetchCallback.onFetch(task.getTaskId());
}
} else if (config.getStopOnTerminate()) {
Log.d(TAG, "- Stopping on terminate");
stop(task.getTaskId());
} else if (config.getJobService() != null) {
try {
task.fireHeadlessEvent(mContext, config);
} catch (BGTask.Error e) {
Log.e(TAG, "Headless task error: " + e.getMessage());
e.printStackTrace();
}
} else {
// {stopOnTerminate: false, forceReload: false} with no Headless JobService?? Don't know what else to do here but stop
Log.w(TAG, "- BackgroundFetch event has occurred while app is terminated but there's no jobService configured to handle the event. BackgroundFetch will terminate.");
finish(task.getTaskId());
stop(task.getTaskId());
}
}
BackgroundFetchConfig getConfig(String taskId) {
synchronized (mConfig) {
return (mConfig.containsKey(taskId)) ? mConfig.get(taskId) : null;
}
}
/**
* @interface BackgroundFetch.Callback
*/
public interface Callback {
void onFetch(String taskId);
void onTimeout(String taskId);
}
}

View File

@@ -1,362 +0,0 @@
package com.transistorsoft.tsbackgroundfetch;
import android.app.job.JobInfo;
import android.content.Context;
import android.content.SharedPreferences;
import android.os.Build;
import android.util.Log;
import org.json.JSONException;
import org.json.JSONObject;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
/**
* Created by chris on 2018-01-11.
*/
public class BackgroundFetchConfig {
private Builder config;
private static final int MINIMUM_FETCH_INTERVAL = 1;
private static final int DEFAULT_FETCH_INTERVAL = 15;
public static final String FIELD_TASK_ID = "taskId";
public static final String FIELD_MINIMUM_FETCH_INTERVAL = "minimumFetchInterval";
public static final String FIELD_START_ON_BOOT = "startOnBoot";
public static final String FIELD_REQUIRED_NETWORK_TYPE = "requiredNetworkType";
public static final String FIELD_REQUIRES_BATTERY_NOT_LOW = "requiresBatteryNotLow";
public static final String FIELD_REQUIRES_CHARGING = "requiresCharging";
public static final String FIELD_REQUIRES_DEVICE_IDLE = "requiresDeviceIdle";
public static final String FIELD_REQUIRES_STORAGE_NOT_LOW = "requiresStorageNotLow";
public static final String FIELD_STOP_ON_TERMINATE = "stopOnTerminate";
public static final String FIELD_JOB_SERVICE = "jobService";
public static final String FIELD_FORCE_ALARM_MANAGER = "forceAlarmManager";
public static final String FIELD_PERIODIC = "periodic";
public static final String FIELD_DELAY = "delay";
public static final String FIELD_IS_FETCH_TASK = "isFetchTask";
public static class Builder {
private String taskId;
private int minimumFetchInterval = DEFAULT_FETCH_INTERVAL;
private long delay = -1;
private boolean periodic = false;
private boolean forceAlarmManager = false;
private boolean stopOnTerminate = true;
private boolean startOnBoot = false;
private int requiredNetworkType = 0;
private boolean requiresBatteryNotLow = false;
private boolean requiresCharging = false;
private boolean requiresDeviceIdle = false;
private boolean requiresStorageNotLow = false;
private boolean isFetchTask = false;
private String jobService = null;
public Builder setTaskId(String taskId) {
this.taskId = taskId;
return this;
}
public Builder setIsFetchTask(boolean value) {
this.isFetchTask = value;
return this;
}
public Builder setMinimumFetchInterval(int fetchInterval) {
if (fetchInterval >= MINIMUM_FETCH_INTERVAL) {
this.minimumFetchInterval = fetchInterval;
}
return this;
}
public Builder setStopOnTerminate(boolean stopOnTerminate) {
this.stopOnTerminate = stopOnTerminate;
return this;
}
public Builder setStartOnBoot(boolean startOnBoot) {
this.startOnBoot = startOnBoot;
return this;
}
public Builder setRequiredNetworkType(int networkType) {
if (android.os.Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP_MR1) {
if (
(networkType != JobInfo.NETWORK_TYPE_ANY) &&
(networkType != JobInfo.NETWORK_TYPE_CELLULAR) &&
(networkType != JobInfo.NETWORK_TYPE_NONE) &&
(networkType != JobInfo.NETWORK_TYPE_NOT_ROAMING) &&
(networkType != JobInfo.NETWORK_TYPE_UNMETERED)
) {
Log.e(BackgroundFetch.TAG, "[ERROR] Invalid " + FIELD_REQUIRED_NETWORK_TYPE + ": " + networkType + "; Defaulting to NETWORK_TYPE_NONE");
networkType = JobInfo.NETWORK_TYPE_NONE;
}
this.requiredNetworkType = networkType;
}
return this;
}
public Builder setRequiresBatteryNotLow(boolean value) {
this.requiresBatteryNotLow = value;
return this;
}
public Builder setRequiresCharging(boolean value) {
this.requiresCharging = value;
return this;
}
public Builder setRequiresDeviceIdle(boolean value) {
this.requiresDeviceIdle = value;
return this;
}
public Builder setRequiresStorageNotLow(boolean value) {
this.requiresStorageNotLow = value;
return this;
}
public Builder setJobService(String className) {
this.jobService = className;
return this;
}
public Builder setForceAlarmManager(boolean value) {
this.forceAlarmManager = value;
return this;
}
public Builder setPeriodic(boolean value) {
this.periodic = value;
return this;
}
public Builder setDelay(long value) {
this.delay = value;
return this;
}
public BackgroundFetchConfig build() {
return new BackgroundFetchConfig(this);
}
public BackgroundFetchConfig load(Context context, String taskId) {
SharedPreferences preferences = context.getSharedPreferences(BackgroundFetch.TAG + ":" + taskId, 0);
if (preferences.contains(FIELD_TASK_ID)) {
setTaskId(preferences.getString(FIELD_TASK_ID, taskId));
}
if (preferences.contains(FIELD_IS_FETCH_TASK)) {
setIsFetchTask(preferences.getBoolean(FIELD_IS_FETCH_TASK, isFetchTask));
}
if (preferences.contains(FIELD_MINIMUM_FETCH_INTERVAL)) {
setMinimumFetchInterval(preferences.getInt(FIELD_MINIMUM_FETCH_INTERVAL, minimumFetchInterval));
}
if (preferences.contains(FIELD_STOP_ON_TERMINATE)) {
setStopOnTerminate(preferences.getBoolean(FIELD_STOP_ON_TERMINATE, stopOnTerminate));
}
if (preferences.contains(FIELD_REQUIRED_NETWORK_TYPE)) {
setRequiredNetworkType(preferences.getInt(FIELD_REQUIRED_NETWORK_TYPE, requiredNetworkType));
}
if (preferences.contains(FIELD_REQUIRES_BATTERY_NOT_LOW)) {
setRequiresBatteryNotLow(preferences.getBoolean(FIELD_REQUIRES_BATTERY_NOT_LOW, requiresBatteryNotLow));
}
if (preferences.contains(FIELD_REQUIRES_CHARGING)) {
setRequiresCharging(preferences.getBoolean(FIELD_REQUIRES_CHARGING, requiresCharging));
}
if (preferences.contains(FIELD_REQUIRES_DEVICE_IDLE)) {
setRequiresDeviceIdle(preferences.getBoolean(FIELD_REQUIRES_DEVICE_IDLE, requiresDeviceIdle));
}
if (preferences.contains(FIELD_REQUIRES_STORAGE_NOT_LOW)) {
setRequiresStorageNotLow(preferences.getBoolean(FIELD_REQUIRES_STORAGE_NOT_LOW, requiresStorageNotLow));
}
if (preferences.contains(FIELD_START_ON_BOOT)) {
setStartOnBoot(preferences.getBoolean(FIELD_START_ON_BOOT, startOnBoot));
}
if (preferences.contains(FIELD_JOB_SERVICE)) {
setJobService(preferences.getString(FIELD_JOB_SERVICE, null));
}
if (preferences.contains(FIELD_FORCE_ALARM_MANAGER)) {
setForceAlarmManager(preferences.getBoolean(FIELD_FORCE_ALARM_MANAGER, forceAlarmManager));
}
if (preferences.contains(FIELD_PERIODIC)) {
setPeriodic(preferences.getBoolean(FIELD_PERIODIC, periodic));
}
if (preferences.contains(FIELD_DELAY)) {
setDelay(preferences.getLong(FIELD_DELAY, delay));
}
return new BackgroundFetchConfig(this);
}
}
private BackgroundFetchConfig(Builder builder) {
config = builder;
// Validate config
if (config.jobService == null) {
if (!config.stopOnTerminate) {
Log.w(BackgroundFetch.TAG, "- Configuration error: In order to use stopOnTerminate: false, you must set enableHeadless: true");
config.setStopOnTerminate(true);
}
if (config.startOnBoot) {
Log.w(BackgroundFetch.TAG, "- Configuration error: In order to use startOnBoot: true, you must enableHeadless: true");
config.setStartOnBoot(false);
}
}
}
void save(Context context) {
SharedPreferences preferences = context.getSharedPreferences(BackgroundFetch.TAG, 0);
Set<String> taskIds = preferences.getStringSet("tasks", new HashSet<String>());
if (taskIds == null) {
taskIds = new HashSet<>();
}
if (!taskIds.contains(config.taskId)) {
Set<String> newIds = new HashSet<>(taskIds);
newIds.add(config.taskId);
SharedPreferences.Editor editor = preferences.edit();
editor.putStringSet("tasks", newIds);
editor.apply();
}
SharedPreferences.Editor editor = context.getSharedPreferences(BackgroundFetch.TAG + ":" + config.taskId, 0).edit();
editor.putString(FIELD_TASK_ID, config.taskId);
editor.putBoolean(FIELD_IS_FETCH_TASK, config.isFetchTask);
editor.putInt(FIELD_MINIMUM_FETCH_INTERVAL, config.minimumFetchInterval);
editor.putBoolean(FIELD_STOP_ON_TERMINATE, config.stopOnTerminate);
editor.putBoolean(FIELD_START_ON_BOOT, config.startOnBoot);
editor.putInt(FIELD_REQUIRED_NETWORK_TYPE, config.requiredNetworkType);
editor.putBoolean(FIELD_REQUIRES_BATTERY_NOT_LOW, config.requiresBatteryNotLow);
editor.putBoolean(FIELD_REQUIRES_CHARGING, config.requiresCharging);
editor.putBoolean(FIELD_REQUIRES_DEVICE_IDLE, config.requiresDeviceIdle);
editor.putBoolean(FIELD_REQUIRES_STORAGE_NOT_LOW, config.requiresStorageNotLow);
editor.putString(FIELD_JOB_SERVICE, config.jobService);
editor.putBoolean(FIELD_FORCE_ALARM_MANAGER, config.forceAlarmManager);
editor.putBoolean(FIELD_PERIODIC, config.periodic);
editor.putLong(FIELD_DELAY, config.delay);
editor.apply();
}
void destroy(Context context) {
SharedPreferences preferences = context.getSharedPreferences(BackgroundFetch.TAG, 0);
Set<String> taskIds = preferences.getStringSet("tasks", new HashSet<String>());
if (taskIds == null) {
taskIds = new HashSet<>();
}
if (taskIds.contains(config.taskId)) {
Set<String> newIds = new HashSet<>(taskIds);
newIds.remove(config.taskId);
SharedPreferences.Editor editor = preferences.edit();
editor.putStringSet("tasks", newIds);
editor.apply();
}
if (!config.isFetchTask) {
SharedPreferences.Editor editor = context.getSharedPreferences(BackgroundFetch.TAG + ":" + config.taskId, 0).edit();
editor.clear();
editor.apply();
}
}
static int FETCH_JOB_ID = 999;
boolean isFetchTask() {
return config.isFetchTask;
}
public String getTaskId() { return config.taskId; }
public int getMinimumFetchInterval() {
return config.minimumFetchInterval;
}
public int getRequiredNetworkType() { return config.requiredNetworkType; }
public boolean getRequiresBatteryNotLow() { return config.requiresBatteryNotLow; }
public boolean getRequiresCharging() { return config.requiresCharging; }
public boolean getRequiresDeviceIdle() { return config.requiresDeviceIdle; }
public boolean getRequiresStorageNotLow() { return config.requiresStorageNotLow; }
public boolean getStopOnTerminate() {
return config.stopOnTerminate;
}
public boolean getStartOnBoot() {
return config.startOnBoot;
}
public String getJobService() { return config.jobService; }
public boolean getForceAlarmManager() {
return config.forceAlarmManager;
}
public boolean getPeriodic() {
return config.periodic || isFetchTask();
}
public long getDelay() {
return config.delay;
}
int getJobId() {
if (config.forceAlarmManager) {
return 0;
} else {
return (isFetchTask()) ? FETCH_JOB_ID : config.taskId.hashCode();
}
}
public String toString() {
JSONObject output = new JSONObject();
try {
output.put(FIELD_TASK_ID, config.taskId);
output.put(FIELD_IS_FETCH_TASK, config.isFetchTask);
output.put(FIELD_MINIMUM_FETCH_INTERVAL, config.minimumFetchInterval);
output.put(FIELD_STOP_ON_TERMINATE, config.stopOnTerminate);
output.put(FIELD_REQUIRED_NETWORK_TYPE, config.requiredNetworkType);
output.put(FIELD_REQUIRES_BATTERY_NOT_LOW, config.requiresBatteryNotLow);
output.put(FIELD_REQUIRES_CHARGING, config.requiresCharging);
output.put(FIELD_REQUIRES_DEVICE_IDLE, config.requiresDeviceIdle);
output.put(FIELD_REQUIRES_STORAGE_NOT_LOW, config.requiresStorageNotLow);
output.put(FIELD_START_ON_BOOT, config.startOnBoot);
output.put(FIELD_JOB_SERVICE, config.jobService);
output.put(FIELD_FORCE_ALARM_MANAGER, config.forceAlarmManager);
output.put(FIELD_PERIODIC, getPeriodic());
output.put(FIELD_DELAY, config.delay);
return output.toString(2);
} catch (JSONException e) {
e.printStackTrace();
return output.toString();
}
}
static void load(final Context context, final OnLoadCallback callback) {
BackgroundFetch.getThreadPool().execute(new Runnable() {
@Override
public void run() {
final List<BackgroundFetchConfig> result = new ArrayList<>();
SharedPreferences preferences = context.getSharedPreferences(BackgroundFetch.TAG, 0);
Set<String> taskIds = preferences.getStringSet("tasks", new HashSet<String>());
if (taskIds != null) {
for (String taskId : taskIds) {
result.add(new BackgroundFetchConfig.Builder().load(context, taskId));
}
}
BackgroundFetch.getUiHandler().post(new Runnable() {
@Override public void run() {
callback.onLoad(result);
}
});
}
});
}
interface OnLoadCallback {
void onLoad(List<BackgroundFetchConfig>config);
}
}

View File

@@ -1,24 +0,0 @@
package com.transistorsoft.tsbackgroundfetch;
import android.content.BroadcastReceiver;
import android.content.Context;
import android.content.Intent;
import android.util.Log;
/**
* Created by chris on 2018-01-15.
*/
public class BootReceiver extends BroadcastReceiver {
@Override
public void onReceive(final Context context, Intent intent) {
String action = intent.getAction();
Log.d(BackgroundFetch.TAG, "BootReceiver: " + action);
BackgroundFetch.getThreadPool().execute(new Runnable() {
@Override public void run() {
BackgroundFetch.getInstance(context.getApplicationContext()).onBoot();
}
});
}
}

View File

@@ -1,40 +0,0 @@
package com.transistorsoft.tsbackgroundfetch;
import android.content.BroadcastReceiver;
import android.content.Context;
import android.content.Intent;
import android.os.PowerManager;
import android.util.Log;
import static android.content.Context.POWER_SERVICE;
/**
* Created by chris on 2018-01-11.
*/
public class FetchAlarmReceiver extends BroadcastReceiver {
@Override
public void onReceive(final Context context, Intent intent) {
PowerManager powerManager = (PowerManager) context.getSystemService(POWER_SERVICE);
final PowerManager.WakeLock wakeLock = powerManager.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, BackgroundFetch.TAG + "::" + intent.getAction());
// WakeLock expires in MAX_TIME + 4s buffer.
wakeLock.acquire((BGTask.MAX_TIME + 4000));
final String taskId = intent.getAction();
final FetchJobService.CompletionHandler completionHandler = new FetchJobService.CompletionHandler() {
@Override
public void finish() {
if (wakeLock.isHeld()) {
wakeLock.release();
Log.d(BackgroundFetch.TAG, "- FetchAlarmReceiver finish");
}
}
};
BGTask task = new BGTask(context, taskId, completionHandler, 0);
BackgroundFetch.getInstance(context.getApplicationContext()).onFetch(task);
}
}

View File

@@ -1,59 +0,0 @@
package com.transistorsoft.tsbackgroundfetch;
import android.annotation.TargetApi;
import android.app.job.JobParameters;
import android.app.job.JobService;
import android.os.PersistableBundle;
import android.util.Log;
/**
* Created by chris on 2018-01-11.
*/
@TargetApi(21)
public class FetchJobService extends JobService {
@Override
public boolean onStartJob(final JobParameters params) {
PersistableBundle extras = params.getExtras();
long scheduleAt = extras.getLong("scheduled_at");
long dt = System.currentTimeMillis() - scheduleAt;
// Scheduled < 1s ago? Ignore.
if (dt < 1000) {
// JobScheduler always immediately fires an initial event on Periodic jobs -- We IGNORE these.
jobFinished(params, false);
return true;
}
final String taskId = extras.getString(BackgroundFetchConfig.FIELD_TASK_ID);
CompletionHandler completionHandler = new CompletionHandler() {
@Override
public void finish() {
Log.d(BackgroundFetch.TAG, "- jobFinished");
jobFinished(params, false);
}
};
BGTask task = new BGTask(this, taskId, completionHandler, params.getJobId());
BackgroundFetch.getInstance(getApplicationContext()).onFetch(task);
return true;
}
@Override
public boolean onStopJob(final JobParameters params) {
Log.d(BackgroundFetch.TAG, "- onStopJob");
PersistableBundle extras = params.getExtras();
final String taskId = extras.getString(BackgroundFetchConfig.FIELD_TASK_ID);
BGTask task = BGTask.getTask(taskId);
if (task != null) {
task.onTimeout(getApplicationContext());
}
jobFinished(params, false);
return true;
}
public interface CompletionHandler {
void finish();
}
}

View File

@@ -1,225 +0,0 @@
package com.transistorsoft.tsbackgroundfetch;
import android.os.Handler;
import android.os.Looper;
import android.util.Log;
import androidx.annotation.NonNull;
import androidx.lifecycle.DefaultLifecycleObserver;
import androidx.lifecycle.LifecycleOwner;
import androidx.lifecycle.ProcessLifecycleOwner;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.atomic.AtomicBoolean;
/**
* Component for managing app life-cycle changes, including headless-mode.
*/
public class LifecycleManager implements DefaultLifecycleObserver, Runnable {
private static LifecycleManager sInstance;
public static LifecycleManager getInstance() {
if (sInstance == null) {
sInstance = getInstanceSynchronized();
}
return sInstance;
}
private static synchronized LifecycleManager getInstanceSynchronized() {
if (sInstance == null) sInstance = new LifecycleManager();
return sInstance;
}
private final List<OnHeadlessChangeCallback> mHeadlessChangeCallbacks = new ArrayList<>();
private final List<OnStateChangeCallback> mStateChangeCallbacks = new ArrayList<>();
private final Handler mHandler;
private Runnable mHeadlessChangeEvent;
private final AtomicBoolean mIsBackground = new AtomicBoolean(true);
private final AtomicBoolean mIsHeadless = new AtomicBoolean(true);
private final AtomicBoolean mStarted = new AtomicBoolean(false);
private final AtomicBoolean mPaused = new AtomicBoolean(false);
private LifecycleManager() {
mHandler = new Handler(Looper.getMainLooper());
onHeadlessChange(isHeadless -> {
if (isHeadless) {
Log.d(BackgroundFetch.TAG, "☯️ HeadlessMode? " + isHeadless);
}
});
}
/**
* Temporarily disable responding to pause/resume events. This was placed here for handling TSLocationManagerActivity events
* whose presentation causes onPause / onResume events that we don't want to react to.
*/
public void pause() {
mPaused.set(true);
}
/**
* Re-engage responding to pause/resume events.
*/
public void resume() {
mPaused.set(false);
}
/**
* Are we in the background?
* @return boolean
*/
public boolean isBackground() {
return mIsBackground.get();
}
/**
* Are we headless
* @return boolean
*/
public boolean isHeadless() {
return mIsHeadless.get();
}
/**
* Explicitly state that we are headless. Probably called when MainActivity is known to have been destroyed.
* @param value boolean
*/
public void setHeadless(boolean value) {
mIsHeadless.set(value);
if (mIsHeadless.get()) {
Log.d(BackgroundFetch.TAG,"☯️ HeadlessMode? " + mIsHeadless);
}
if (mHeadlessChangeEvent != null) {
mHandler.removeCallbacks(mHeadlessChangeEvent);
mStarted.set(true);
fireHeadlessChangeListeners();
}
}
/**
* Register Headless-mode change listener.
*/
public void onHeadlessChange(OnHeadlessChangeCallback callback) {
if (mStarted.get()) {
callback.onChange(mIsHeadless.get());
return;
}
synchronized (mHeadlessChangeCallbacks) {
mHeadlessChangeCallbacks.add(callback);
}
}
/**
* Register pause/resume listener.
*/
public void onStateChange(OnStateChangeCallback callback) {
synchronized (mStateChangeCallbacks) {
mStateChangeCallbacks.add(callback);
}
}
/**
* Regiser the LifecycleObserver
*/
@Override
public void run() {
ProcessLifecycleOwner.get().getLifecycle().addObserver(this);
}
@Override
public void onCreate(@NonNull LifecycleOwner owner) {
Log.d(BackgroundFetch.TAG,"☯️ onCreate");
// If this 50ms Timer fires before onStart, we are headless
mHeadlessChangeEvent = new Runnable() {
@Override public void run() {
mStarted.set(true);
fireHeadlessChangeListeners();
}
};
mHandler.postDelayed(mHeadlessChangeEvent, 50);
mIsHeadless.set(true);
mIsBackground.set(true);
}
@Override
public void onStart(@NonNull LifecycleOwner owner) {
Log.d(BackgroundFetch.TAG, "☯️ onStart");
// Cancel StateChange Timer.
if (mPaused.get()) {
return;
}
if (mHeadlessChangeEvent != null) {
mHandler.removeCallbacks(mHeadlessChangeEvent);
}
mStarted.set(true);
mIsHeadless.set(false);
mIsBackground.set(false);
// Fire listeners.
fireHeadlessChangeListeners();
}
@Override
public void onDestroy(@NonNull LifecycleOwner owner) {
Log.d(BackgroundFetch.TAG, "☯️ onDestroy");
mIsBackground.set(true);
mIsHeadless.set(true);
}
@Override
public void onStop(@NonNull LifecycleOwner owner) {
Log.d(BackgroundFetch.TAG, "☯️ onStop");
if (mPaused.compareAndSet(true, false)) {
return;
}
mIsBackground.set(true);
}
@Override
public void onPause(@NonNull LifecycleOwner owner) {
Log.d(BackgroundFetch.TAG, "☯️ onPause");
mIsBackground.set(true);
fireStateChangeListeners(false);
}
@Override
public void onResume(@NonNull LifecycleOwner owner) {
Log.d(BackgroundFetch.TAG, "☯️ onResume");
if (mPaused.get()) {
return;
}
mIsBackground.set(false);
mIsHeadless.set(false);
fireStateChangeListeners(true);
}
/// Fire pause/resume change listeners
private void fireStateChangeListeners(boolean isForeground) {
synchronized (mStateChangeCallbacks) {
for (OnStateChangeCallback callback : mStateChangeCallbacks) {
callback.onChange(isForeground);
}
}
}
/// Fire headless mode change listeners.
private void fireHeadlessChangeListeners() {
if (mHeadlessChangeEvent != null) {
mHandler.removeCallbacks(mHeadlessChangeEvent);
mHeadlessChangeEvent = null;
}
synchronized (mHeadlessChangeCallbacks) {
for (OnHeadlessChangeCallback callback : mHeadlessChangeCallbacks) {
callback.onChange(mIsHeadless.get());
}
mHeadlessChangeCallbacks.clear();
}
}
public interface OnHeadlessChangeCallback {
void onChange(boolean isHeadless);
}
public interface OnStateChangeCallback {
void onChange(boolean isForeground);
}
}

View File

@@ -1,3 +0,0 @@
<resources>
<string name="app_name">TSBackgroundFetch</string>
</resources>

View File

@@ -1,17 +0,0 @@
package com.transistorsoft.tsbackgroundfetch;
import org.junit.Test;
import static org.junit.Assert.*;
/**
* Example local unit test, which will execute on the development machine (host).
*
* @see <a href="http://d.android.com/tools/testing">Testing documentation</a>
*/
public class ExampleUnitTest {
@Test
public void addition_isCorrect() throws Exception {
assertEquals(4, 2 + 2);
}
}