Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Import Contacts from phone's contact book (#952) #966

Open
wants to merge 18 commits into
base: main
Choose a base branch
from
1 change: 1 addition & 0 deletions android/app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
<uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.permission.MANAGE_OWN_CALLS"/>
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
<uses-permission android:name="android.permission.READ_CONTACTS" />
<uses-permission android:name="android.permission.READ_PHONE_NUMBERS" />
<uses-permission android:name="android.permission.READ_PHONE_STATE" android:maxSdkVersion="29" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
Expand Down
10 changes: 6 additions & 4 deletions ios/Runner/Info.plist
Original file line number Diff line number Diff line change
Expand Up @@ -58,9 +58,11 @@
<string>We need access to your microphone to record it</string>
<key>LSSupportsOpeningDocumentsInPlace</key>
<true/>
<key>CADisableMinimumFrameDurationOnPhone</key>
<true/>
<key>UIApplicationSupportsIndirectInputEvents</key>
<true/>
<key>CADisableMinimumFrameDurationOnPhone</key>
<true/>
<key>UIApplicationSupportsIndirectInputEvents</key>
<true/>
<key>NSContactsUsageDescription</key>
<string>We need access to your contacts to import them.</string>
</dict>
</plist>
11 changes: 10 additions & 1 deletion lib/domain/model/user.dart
Original file line number Diff line number Diff line change
Expand Up @@ -372,7 +372,14 @@ class UserBio extends NewType<String> {
class UserPhone extends NewType<String> {
const UserPhone._(super.val);

UserPhone(String val) : super(val) {
factory UserPhone(String val) {
// Normalize phone number.
val = val.replaceAll(RegExp(r'[^\d+]'), '');

if (val.startsWith('0')) {
val = '+38$val';
}

if (!val.startsWith('+')) {
throw const FormatException('Must start with plus');
}
Expand All @@ -384,6 +391,8 @@ class UserPhone extends NewType<String> {
if (!_regExp.hasMatch(val)) {
throw const FormatException('Does not match validation RegExp');
}

return UserPhone._(val);
}

/// Creates an object without any validation.
Expand Down
7 changes: 3 additions & 4 deletions lib/domain/service/notification.dart
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ import '/ui/worker/cache.dart';
import '/util/android_utils.dart';
import '/util/audio_utils.dart';
import '/util/log.dart';
import '/util/permission.dart';
import '/util/platform_utils.dart';
import '/util/web/web_utils.dart';
import 'disposable_service.dart';
Expand Down Expand Up @@ -445,16 +446,14 @@ class NotificationService extends DisposableService {
Log.error(e.toString(), '$runtimeType');
}
}

NotificationSettings settings =
await FirebaseMessaging.instance.requestPermission();
NotificationSettings settings = await PermissionUtil.notifications();

// On Android first attempt is always [AuthorizationStatus.denied] due to
// notifications request popping while invoking a
// [AndroidUtils.createNotificationChannel], so try again on failure.
if (PlatformUtils.isAndroid &&
settings.authorizationStatus != AuthorizationStatus.authorized) {
settings = await FirebaseMessaging.instance.requestPermission();
settings = await PermissionUtil.notifications();
}

if (settings.authorizationStatus == AuthorizationStatus.authorized) {
Expand Down
14 changes: 13 additions & 1 deletion lib/provider/hive/session_data.dart
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,12 @@ class SessionDataHiveProvider extends HiveBaseProvider<SessionData> {
return getSafe(0)?.blocklistSynchronized;
}

/// Returns the stored [SessionData.contactsImported] from [Hive].
bool? getContactsImported() {
Log.trace('getContactsImported()', '$runtimeType');
return getSafe(0)?.contactsImported;
}

/// Stores a new [FavoriteChatsListVersion] to [Hive].
Future<void> setFavoriteChatsListVersion(FavoriteChatsListVersion ver) {
Log.trace('setChatContactsListVersion($ver)', '$runtimeType');
Expand Down Expand Up @@ -125,10 +131,16 @@ class SessionDataHiveProvider extends HiveBaseProvider<SessionData> {

/// Stores a new [SessionData.blocklistSynchronized] to [Hive].
Future<void> setBlocklistSynchronized(bool val) async {
Log.trace('setBlocklistSynchronized()', '$runtimeType');
Log.trace('setBlocklistSynchronized($val)', '$runtimeType');
await putSafe(
0,
(box.get(0) ?? SessionData())..blocklistSynchronized = val,
);
}

/// Stores a new [SessionData.contactsImported] to [Hive].
Future<void> setContactsImported(bool val) async {
Log.trace('setContactsImported($val)', '$runtimeType');
await putSafe(0, (box.get(0) ?? SessionData())..contactsImported = val);
}
}
66 changes: 66 additions & 0 deletions lib/store/contact.dart
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,11 @@ import 'dart:async';

import 'package:async/async.dart';
import 'package:collection/collection.dart';
import 'package:fast_contacts/fast_contacts.dart';
import 'package:get/get.dart';
import 'package:hive/hive.dart';
import 'package:mutex/mutex.dart';
import 'package:permission_handler/permission_handler.dart';

import '/api/backend/extension/contact.dart';
import '/api/backend/extension/page_info.dart';
Expand All @@ -45,6 +47,8 @@ import '/store/pagination/graphql.dart';
import '/util/log.dart';
import '/util/new_type.dart';
import '/util/obs/obs.dart';
import '/util/permission.dart';
import '/util/platform_utils.dart';
import '/util/stream_utils.dart';
import 'event/contact.dart';
import 'model/contact.dart';
Expand Down Expand Up @@ -129,6 +133,12 @@ class ContactRepository extends DisposableInterface
_initLocalSubscription();
_initRemoteSubscription();

if (PlatformUtils.isMobile &&
!PlatformUtils.isWeb &&
_sessionLocal.getContactsImported() != true) {
_importContacts();
}

super.onInit();
}

Expand Down Expand Up @@ -388,6 +398,62 @@ class ContactRepository extends DisposableInterface
await _contactLocal.remove(id);
}

/// Imports contacts from the device's contact list.
Future<void> _importContacts() async {
PermissionStatus status = await Permission.contacts.status;
krida2000 marked this conversation as resolved.
Show resolved Hide resolved

if (status.isPermanentlyDenied || status.isRestricted) {
return;
}

if (!status.isGranted) {
status = await PermissionUtil.contacts();

if (!status.isGranted) {
return;
}
}

final List<Contact> contacts = await FastContacts.getAllContacts();

for (final Contact contact in contacts) {
final List<UserPhone> phones = [];
final List<UserEmail> emails = [];

for (var e in contact.phones) {
try {
phones.add(UserPhone(e.number));
} catch (_) {
// No-op.
}
}

for (var e in contact.emails) {
try {
emails.add(UserEmail(e.address));
} catch (_) {
// No-op.
}
}

try {
if (phones.isNotEmpty || emails.isNotEmpty) {
_graphQlProvider.createChatContact(
name: UserName(contact.displayName.padRight(2, '_')),
records: [
...phones.map((e) => ChatContactRecord(phone: e)),
...emails.map((e) => ChatContactRecord(email: e)),
],
);
krida2000 marked this conversation as resolved.
Show resolved Hide resolved
}
} catch (_) {
// No-op.
}
}

await _sessionLocal.setContactsImported(true);
}

/// Searches [ChatContact]s by the provided [UserName].
///
/// This is a fuzzy search.
Expand Down
5 changes: 5 additions & 0 deletions lib/store/model/session_data.dart
Original file line number Diff line number Diff line change
Expand Up @@ -54,4 +54,9 @@ class SessionData extends HiveObject {
/// remote, meaning no queries should be made.
@HiveField(5)
bool? blocklistSynchronized;

/// Persisted indicator whether contacts from device's contacts book was
/// imported.
@HiveField(6)
bool? contactsImported;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Хм, кстати, а что будет, если контакты уже были импортированы, а мы заново этих же контактов импортируем? И, может, эту штуку в ApplicationSettings затолкать, чтобы оно жило дольше, чем текущая сессия? У нас там introduction и лежит уже.

Copy link
Contributor Author

@krida2000 krida2000 Apr 18, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@SleepySquash

Хм, кстати, а что будет, если контакты уже были импортированы, а мы заново этих же контактов импортируем?

Тогда у нас все контакты продублируются. Но я пока не вижу как мы можем от этого защититься с нашей стороны, так как любой индикатор который мы будем хранить локально может быть удален при очистке кеша, либо при удалении приложения. Думаю тут со стороны бекенда нужно менять логику создания контактов, чтобы у нас не могли добавляться несколько контактов с одинаковыми номерами/емейлами.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@krida2000, да, логику однозначно нужно корректировать там. Вот отсюда у меня и была просьба оценить и исследовать сначала, насколько при текущей концепции контактов вообще возможны все приседания, которые мы хотим сделать. Мне кажется, что это очень даже blocker тут.

Идеи пока две в таком случае:

  1. Закомментировать отправку создания контактов на бэкэнд, пометив тудушкой.
  2. Пометить PR как waiting:materials и ждать исправления этого момента.

И я пока придерживаюсь второго варианта, чтобы в prod'е не было недоделок.

}
6 changes: 3 additions & 3 deletions lib/ui/page/call/search/controller.dart
Original file line number Diff line number Diff line change
Expand Up @@ -585,11 +585,11 @@ class SearchController extends GetxController {

// Predicates to filter the [allContacts] by.
bool isMember(RxChatContact c) =>
chat?.members.items.containsKey(c.user.value!.id) ?? false;
bool inRecent(RxChatContact c) => recent.containsKey(c.user.value!.id);
chat?.members.items.containsKey(c.user.value?.id) ?? false;
bool inRecent(RxChatContact c) => recent.containsKey(c.user.value?.id);
bool inChats(RxChatContact c) => chats.values.any((chat) =>
chat.chat.value.isDialog &&
chat.members.items.containsKey(c.user.value!.id));
chat.members.items.containsKey(c.user.value?.id));
bool matchesQuery(RxChatContact c) => _matchesQuery(user: c.user.value);

final List<RxChatContact> filtered = allContacts
Expand Down
2 changes: 2 additions & 0 deletions lib/ui/page/home/tab/chats/view.dart
Original file line number Diff line number Diff line change
Expand Up @@ -308,6 +308,8 @@ class ChatsTabView extends StatelessWidget {
c.search.value?.search.clear();
c.search.value?.query.value = '';
c.search.value?.search.focus.requestFocus();
} else {
c.closeSearch(true);
SleepySquash marked this conversation as resolved.
Show resolved Hide resolved
}
} else if (c.selecting.value) {
c.toggleSelecting();
Expand Down
36 changes: 23 additions & 13 deletions lib/ui/page/home/tab/contacts/controller.dart
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import 'dart:async';

import 'package:async/async.dart';
import 'package:back_button_interceptor/back_button_interceptor.dart';
import 'package:collection/collection.dart';
import 'package:flutter/material.dart' hide SearchController;
import 'package:flutter/scheduler.dart';
import 'package:flutter/services.dart';
Expand Down Expand Up @@ -140,6 +141,7 @@ class ContactsTabController extends GetxController {
scrollController.addListener(_scrollListener);

contacts.value = _contactService.paginated.values
.where((e) => e.contact.value.users.isNotEmpty)
.map((e) => ContactEntry(e))
.toList()
..sort();
Expand Down Expand Up @@ -427,10 +429,12 @@ class ContactsTabController extends GetxController {
_contactsSubscription = _contactService.paginated.changes.listen((e) {
switch (e.op) {
case OperationKind.added:
final entry = ContactEntry(e.value!);
contacts.add(entry);
contacts.sort();
listen(entry);
if (e.value!.contact.value.users.isNotEmpty) {
final entry = ContactEntry(e.value!);
contacts.add(entry);
contacts.sort();
listen(entry);
}
break;

case OperationKind.removed:
Expand All @@ -440,7 +444,14 @@ class ContactsTabController extends GetxController {
break;

case OperationKind.updated:
contacts.sort();
if (e.value!.contact.value.users.isNotEmpty) {
if (contacts.none((c) => c.id == e.key)) {
final entry = ContactEntry(e.value!);
contacts.add(entry);
}

contacts.sort();
}
krida2000 marked this conversation as resolved.
Show resolved Hide resolved
break;
}
});
Expand Down Expand Up @@ -474,15 +485,16 @@ class ContactsTabController extends GetxController {
if (!_scrollIsInvoked) {
_scrollIsInvoked = true;

SchedulerBinding.instance.addPostFrameCallback((_) {
SchedulerBinding.instance.addPostFrameCallback((_) async {
_scrollIsInvoked = false;

if (scrollController.hasClients &&
hasNext.isTrue &&
_contactService.nextLoading.isFalse &&
scrollController.position.pixels >
scrollController.position.maxScrollExtent - 500) {
_contactService.next();
await _contactService.next();
_scrollListener();
}
});
}
Expand All @@ -500,14 +512,12 @@ class ContactsTabController extends GetxController {
return;
}

if (!scrollController.hasClients) {
return await _ensureScrollable();
}

// If the fetched initial page contains less elements than required to
// fill the view and there's more pages available, then fetch those pages.
if (scrollController.position.maxScrollExtent < 50 &&
_contactService.nextLoading.isFalse) {
if ((!scrollController.hasClients ||
scrollController.position.maxScrollExtent < 50) &&
_contactService.nextLoading.isFalse &&
hasNext.isTrue) {
await _contactService.next();
_ensureScrollable();
}
Expand Down
2 changes: 2 additions & 0 deletions lib/ui/page/home/tab/contacts/view.dart
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,8 @@ class ContactsTabView extends StatelessWidget {
c.search.value?.search.clear();
c.search.value?.query.value = '';
c.search.value?.search.focus.requestFocus();
} else {
c.toggleSearch(false);
}
} else if (c.selecting.value) {
c.toggleSelecting();
Expand Down
42 changes: 42 additions & 0 deletions lib/util/permission.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
// Copyright © 2022-2024 IT ENGINEERING MANAGEMENT INC,
// <https://github.com/team113>
//
// This program is free software: you can redistribute it and/or modify it under
// the terms of the GNU Affero General Public License v3.0 as published by the
// Free Software Foundation, either version 3 of the License, or (at your
// option) any later version.
//
// This program is distributed in the hope that it will be useful, but WITHOUT
// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
// FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License v3.0 for
// more details.
//
// You should have received a copy of the GNU Affero General Public License v3.0
// along with this program. If not, see
// <https://www.gnu.org/licenses/agpl-3.0.html>.

import 'package:firebase_messaging/firebase_messaging.dart';
import 'package:mutex/mutex.dart';
import 'package:permission_handler/permission_handler.dart';

/// Utility class for requesting permissions.
class PermissionUtil {
krida2000 marked this conversation as resolved.
Show resolved Hide resolved
/// Mutex for synchronized access to permissions requesting.
///
/// Ensures that only one permission is requested at the same time.
static final Mutex _permissionMutex = Mutex();

/// Requests the notifications permission.
static Future<NotificationSettings> notifications() {
return _permissionMutex.protect(() {
return FirebaseMessaging.instance.requestPermission();
});
}

/// Requests the contacts permission.
static Future<PermissionStatus> contacts() {
return _permissionMutex.protect(() async {
return Permission.contacts.request();
});
}
krida2000 marked this conversation as resolved.
Show resolved Hide resolved
}
8 changes: 8 additions & 0 deletions pubspec.lock
Original file line number Diff line number Diff line change
Expand Up @@ -370,6 +370,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.3.1"
fast_contacts:
dependency: "direct main"
description:
name: fast_contacts
sha256: cdc0091af580db3fe848decf7c7fc50ae2e08ac2d57694491801cd5eab6fcf4e
url: "https://pub.dev"
source: hosted
version: "3.1.3"
ffi:
dependency: "direct main"
description:
Expand Down
1 change: 1 addition & 0 deletions pubspec.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ dependencies:
url: https://github.com/krida2000/dough
path: packages/dough/
email_validator: ^2.1.17
fast_contacts: ^3.1.3
ffi: ^2.0.2
file_picker: ^6.1.1
firebase_core: ^2.25.4
Expand Down
Loading
Loading