feat(service): First attempt at handling phone contacts

This commit is contained in:
PapaTutuWawa 2022-12-10 19:34:11 +01:00
parent 73913c4ae6
commit e060b0f549
21 changed files with 347 additions and 31 deletions

View File

@ -48,6 +48,7 @@
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.READ_CONTACTS" />
<queries>
<intent>
<action android:name="android.intent.action.VIEW" />

View File

@ -442,6 +442,11 @@ files:
attributes:
deviceId: int
jid: String
- name: GetContactsCommandDebug
extends: BackgroundCommand
implements:
- JsonImplementation
attributes:
generate_builder: true
# get${builder_Name}FromJson
builder_name: "Command"

View File

@ -27,7 +27,6 @@ String _cleanBase64String(String original) {
}
class AvatarService {
AvatarService() : _log = Logger('AvatarService');
final Logger _log;

122
lib/service/contact.dart Normal file
View File

@ -0,0 +1,122 @@
import 'package:flutter_contacts/flutter_contacts.dart';
import 'package:get_it/get_it.dart';
import 'package:logging/logging.dart';
import 'package:moxxyv2/service/conversation.dart';
import 'package:moxxyv2/service/database/database.dart';
import 'package:moxxyv2/service/roster.dart';
import 'package:moxxyv2/service/service.dart';
import 'package:moxxyv2/shared/events.dart';
import 'package:moxxyv2/shared/models/roster.dart';
class ContactWrapper {
const ContactWrapper(this.title, this.id, this.jid);
final String title;
final String id;
final String jid;
}
class ContactsService {
ContactsService() : _log = Logger('ContactsService') {
// NOTE: Apparently, this means that if false, contacts that are in 0 groups
// are not returned.
FlutterContacts.config.includeNonVisibleOnAndroid = true;
// Allow us to react to database changes
FlutterContacts.addListener(_onContactsDatabaseUpdate);
}
final Logger _log;
List<String>? _contactIds;
Future<List<ContactWrapper>> fetchContactsWithJabber() async {
final contacts = await FlutterContacts.getContacts(withProperties: true);
_log.finest('Got ${contacts.length} contacts');
final jabberContacts = List<ContactWrapper>.empty(growable: true);
for (final c in contacts) {
final index = c.socialMedias
.indexWhere((s) => s.label == SocialMediaLabel.jabber);
if (index == -1) continue;
jabberContacts.add(
ContactWrapper(
'${c.name.first} ${c.name.last}',
c.id,
c.socialMedias[index].userName,
),
);
}
_log.finest('${jabberContacts.length} contacts have an XMPP address');
return jabberContacts;
}
Future<void> _onContactsDatabaseUpdate() async {
_log.finest('Got contacts database update');
}
Future<List<String>> _getContactIds() async {
if (_contactIds != null) return _contactIds!;
_contactIds = List<String>.from(
await GetIt.I.get<DatabaseService>().getContactIds(),
);
return _contactIds!;
}
Future<void> scanContacts() async {
final db = GetIt.I.get<DatabaseService>();
final cs = GetIt.I.get<ConversationService>();
final rs = GetIt.I.get<RosterService>();
final contacts = await fetchContactsWithJabber();
final knownContactIds = await _getContactIds();
for (final id in knownContactIds) {
final index = contacts.indexWhere((c) => c.id == id);
if (index != -1) continue;
await db.removeContactId(id);
_contactIds!.remove(id);
}
final modifiedRosterItems = List<RosterItem>.empty(growable: true);
for (final contact in contacts) {
if (!knownContactIds.contains(contact.id)) {
await db.addContactId(contact.id);
_contactIds!.add(contact.id);
}
final c = await cs.getConversationByJid(contact.jid);
if (c != null) {
final newConv = await cs.updateConversation(
c.id,
contactId: contact.id,
);
sendEvent(
ConversationUpdatedEvent(
conversation: newConv,
),
);
}
final r = await rs.getRosterItemByJid(contact.jid);
if (r != null) {
final newRosterItem = await rs.updateRosterItem(
r.id,
contactId: contact.id,
);
modifiedRosterItems.add(newRosterItem);
} else {
// TODO(PapaTutuWawa): Create it
}
}
if (modifiedRosterItems.isNotEmpty) {
sendEvent(
RosterDiffEvent(
modified: modifiedRosterItems,
),
);
}
}
}

View File

@ -2,6 +2,7 @@ import 'package:get_it/get_it.dart';
import 'package:moxlib/moxlib.dart';
import 'package:moxxmpp/moxxmpp.dart';
import 'package:moxxyv2/service/database/database.dart';
import 'package:moxxyv2/service/not_specified.dart';
import 'package:moxxyv2/service/preferences.dart';
import 'package:moxxyv2/shared/cache.dart';
import 'package:moxxyv2/shared/models/conversation.dart';
@ -64,6 +65,7 @@ class ConversationService {
ChatState? chatState,
bool? muted,
bool? encrypted,
Object? contactId = notSpecified,
}) async {
final conversation = (await _getConversationById(id))!;
var newConversation = await GetIt.I.get<DatabaseService>().updateConversation(
@ -76,6 +78,7 @@ class ConversationService {
chatState: conversation.chatState,
muted: muted,
encrypted: encrypted,
contactId: contactId,
);
// Copy over the old lastMessage if a new one was not set

View File

@ -11,6 +11,7 @@ const omemoTrustDeviceListTable = 'OmemoTrustDeviceList';
const omemoTrustEnableListTable = 'OmemoTrustEnableList';
const omemoFingerprintCache = 'OmemoFingerprintCache';
const xmppStateTable = 'XmppState';
const contactsTable = 'Contacts';
const typeString = 0;
const typeInt = 1;

View File

@ -62,21 +62,17 @@ Future<void> createDatabase(Database db, int version) async {
// Conversations
await db.execute(
'''
CREATE TABLE $conversationsTable (
id INTEGER PRIMARY KEY AUTOINCREMENT,
jid TEXT NOT NULL,
title TEXT NOT NULL,
avatarUrl TEXT NOT NULL,
lastChangeTimestamp INTEGER NOT NULL,
unreadCounter INTEGER NOT NULL,
open INTEGER NOT NULL,
muted INTEGER NOT NULL,
encrypted INTEGER NOT NULL,
lastMessageId INTEGER,
CONSTRAINT fk_last_message FOREIGN KEY (lastMessageId) REFERENCES $messagesTable (id)
)''',
''',
);
// Contacts
await db.execute(
'''
CREATE TABLE $contactsTable (
id TEXT PRIMARY KEY
)'''
);
// Shared media
await db.execute(
'''
@ -102,7 +98,10 @@ Future<void> createDatabase(Database db, int version) async {
avatarUrl TEXT NOT NULL,
avatarHash TEXT NOT NULL,
subscription TEXT NOT NULL,
ask TEXT NOT NULL
ask TEXT NOT NULL,
contactId TEXT,
CONSTRAINT fk_contact_id FOREIGN KEY (contactId) REFERENCES $contactsTable (id)
ON DELETE SET NULL
)''',
);

View File

@ -8,6 +8,8 @@ import 'package:moxxmpp/moxxmpp.dart';
import 'package:moxxyv2/service/database/constants.dart';
import 'package:moxxyv2/service/database/creation.dart';
import 'package:moxxyv2/service/database/helpers.dart';
import 'package:moxxyv2/service/database/migrations/0000_contacts_integration.dart';
import 'package:moxxyv2/service/database/migrations/0000_contacts_integration_fixup.dart';
import 'package:moxxyv2/service/database/migrations/0000_conversations.dart';
import 'package:moxxyv2/service/database/migrations/0000_conversations2.dart';
import 'package:moxxyv2/service/database/migrations/0000_conversations3.dart';
@ -67,7 +69,7 @@ class DatabaseService {
_db = await openDatabase(
dbPath,
password: key,
version: 13,
version: 15,
onCreate: createDatabase,
onConfigure: (db) async {
// In order to do schema changes during database upgrades, we disable foreign
@ -128,6 +130,14 @@ class DatabaseService {
_log.finest('Running migration for database version 13');
await upgradeFromV12ToV13(db);
}
if (oldVersion < 14) {
_log.finest('Running migration for database version 14');
await upgradeFromV13ToV14(db);
}
if (oldVersion < 15) {
_log.finest('Running migration for database version 15');
await upgradeFromV14ToV15(db);
}
},
);
@ -209,6 +219,7 @@ class DatabaseService {
ChatState? chatState,
bool? muted,
bool? encrypted,
Object? contactId = notSpecified,
}) async {
final cd = (await _db.query(
'Conversations',
@ -245,6 +256,9 @@ class DatabaseService {
if (encrypted != null) {
c['encrypted'] = boolToInt(encrypted);
}
if (contactId != notSpecified) {
c['contactId'] = contactId as String?;
}
await _db.update(
'Conversations',
@ -636,10 +650,11 @@ class DatabaseService {
String? subscription,
String? ask,
List<String>? groups,
Object? contactId = notSpecified,
}
) async {
final id_ = (await _db.query(
'RosterItems',
rosterTable,
where: 'id = ?',
whereArgs: [id],
limit: 1,
@ -666,9 +681,12 @@ class DatabaseService {
if (ask != null) {
i['ask'] = ask;
}
if (contactId != notSpecified) {
i['contactId'] = contactId as String?;
}
await _db.update(
'RosterItems',
rosterTable,
i,
where: 'id = ?',
whereArgs: [id],
@ -1042,4 +1060,27 @@ class DatabaseService {
})
.toList();
}
Future<List<String>> getContactIds() async {
return (await _db.query(contactsTable))
.map((item) => item['id']! as String)
.toList();
}
Future<void> addContactId(String id) async {
await _db.insert(
contactsTable,
<String, String>{
'id': id,
},
);
}
Future<void> removeContactId(String id) async {
await _db.delete(
contactsTable,
where: 'id = ?',
whereArgs: [id],
);
}
}

View File

@ -0,0 +1,56 @@
import 'package:moxxyv2/service/database/constants.dart';
import 'package:sqflite_sqlcipher/sqflite.dart';
Future<void> upgradeFromV13ToV14(Database db) async {
// Create the new table
await db.execute(
'''
CREATE TABLE $contactsTable (
id TEXT PRIMARY KEY
)'''
);
// Migrate the conversations
await db.execute(
'''
CREATE TABLE ${conversationsTable}_new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
jid TEXT NOT NULL,
title TEXT NOT NULL,
avatarUrl TEXT NOT NULL,
lastChangeTimestamp INTEGER NOT NULL,
unreadCounter INTEGER NOT NULL,
open INTEGER NOT NULL,
muted INTEGER NOT NULL,
encrypted INTEGER NOT NULL,
lastMessageId INTEGER,
contactId TEXT,
CONSTRAINT fk_last_message FOREIGN KEY (lastMessageId) REFERENCES $messagesTable (id),
CONSTRAINT fk_contact_id FOREIGN KEY (contactId) REFERENCES $contactsTable (id)
ON DELETE SET NULL
)''',
);
await db.execute('INSERT INTO ${conversationsTable}_new SELECT *, NULL from $conversationsTable');
await db.execute('DROP TABLE $conversationsTable;');
await db.execute('ALTER TABLE ${conversationsTable}_new RENAME TO $conversationsTable;');
// Migrate the roster items
await db.execute(
'''
CREATE TABLE ${rosterTable}_new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
jid TEXT NOT NULL,
title TEXT NOT NULL,
avatarUrl TEXT NOT NULL,
avatarHash TEXT NOT NULL,
subscription TEXT NOT NULL,
ask TEXT NOT NULL,
contactId TEXT,
CONSTRAINT fk_contact_id FOREIGN KEY (contactId) REFERENCES $contactsTable (id)
ON DELETE SET NULL
)''',
);
await db.execute('INSERT INTO ${rosterTable}_new SELECT *, NULL from $rosterTable');
await db.execute('DROP TABLE $rosterTable;');
await db.execute('ALTER TABLE ${rosterTable}_new RENAME TO $rosterTable;');
}

View File

@ -0,0 +1,15 @@
import 'package:moxxyv2/service/database/constants.dart';
import 'package:sqflite_sqlcipher/sqflite.dart';
Future<void> upgradeFromV14ToV15(Database db) async {
// Add the missing primary key
await db.execute(
'''
CREATE TABLE ${contactsTable}_new (
id TEXT PRIMARY KEY
)''',
);
await db.execute('INSERT INTO ${contactsTable}_new SELECT * from $contactsTable');
await db.execute('DROP TABLE $contactsTable;');
await db.execute('ALTER TABLE ${contactsTable}_new RENAME TO $contactsTable;');
}

View File

@ -8,6 +8,7 @@ import 'package:moxxyv2/i18n/strings.g.dart';
import 'package:moxxyv2/service/avatars.dart';
import 'package:moxxyv2/service/blocking.dart';
import 'package:moxxyv2/service/conversation.dart';
import 'package:moxxyv2/service/contact.dart';
import 'package:moxxyv2/service/database/database.dart';
import 'package:moxxyv2/service/helpers.dart';
import 'package:moxxyv2/service/httpfiletransfer/helpers.dart';
@ -70,6 +71,7 @@ void setupBackgroundEventHandler() {
EventTypeMatcher<AddReactionToMessageCommand>(performAddMessageReaction),
EventTypeMatcher<RemoveReactionFromMessageCommand>(performRemoveMessageReaction),
EventTypeMatcher<MarkOmemoDeviceAsVerifiedCommand>(performMarkDeviceVerified),
EventTypeMatcher<GetContactsCommandDebug>(performGetContacts),
]);
GetIt.I.registerSingleton<EventHandler>(handler);
@ -744,3 +746,7 @@ Future<void> performMarkDeviceVerified(MarkOmemoDeviceAsVerifiedCommand command,
command.jid,
);
}
Future<void> performGetContacts(GetContactsCommandDebug command, { dynamic extra }) async {
await GetIt.I.get<ContactsService>().scanContacts();
}

View File

@ -8,6 +8,7 @@ import 'package:moxplatform/moxplatform.dart';
import 'package:moxxmpp/moxxmpp.dart';
import 'package:moxxyv2/service/conversation.dart';
import 'package:moxxyv2/service/database/database.dart';
import 'package:moxxyv2/service/not_specified.dart';
import 'package:moxxyv2/service/service.dart';
import 'package:moxxyv2/shared/events.dart';
import 'package:moxxyv2/shared/models/conversation.dart';
@ -223,6 +224,7 @@ class RosterService {
String? subscription,
String? ask,
List<String>? groups,
Object? contactId = notSpecified,
}
) async {
final newItem = await GetIt.I.get<DatabaseService>().updateRosterItem(
@ -233,6 +235,7 @@ class RosterService {
subscription: subscription,
ask: ask,
groups: groups,
contactId: contactId,
);
// Update cache

View File

@ -13,6 +13,7 @@ import 'package:moxxyv2/service/avatars.dart';
import 'package:moxxyv2/service/blocking.dart';
import 'package:moxxyv2/service/connectivity.dart';
import 'package:moxxyv2/service/connectivity_watcher.dart';
import 'package:moxxyv2/service/contact.dart';
import 'package:moxxyv2/service/conversation.dart';
import 'package:moxxyv2/service/cryptography/cryptography.dart';
import 'package:moxxyv2/service/database/database.dart';
@ -153,6 +154,7 @@ Future<void> entrypoint() async {
GetIt.I.registerSingleton<MessageService>(MessageService());
GetIt.I.registerSingleton<OmemoService>(OmemoService());
GetIt.I.registerSingleton<CryptographyService>(CryptographyService());
GetIt.I.registerSingleton<ContactsService>(ContactsService());
final xmpp = XmppService();
GetIt.I.registerSingleton<XmppService>(xmpp);

View File

@ -59,6 +59,10 @@ class Conversation with _$Conversation {
bool encrypted,
// The current chat state
@ConversationChatStateConverter() ChatState chatState,
{
// The id of the contact in the device's phonebook if it exists
String? contactId,
}
) = _Conversation;
const Conversation._();

View File

@ -14,6 +14,10 @@ class RosterItem with _$RosterItem {
String subscription,
String ask,
List<String> groups,
{
// The id of the contact in the device's phonebook if it exists
String? contactId,
}
) = _RosterItem;
const RosterItem._();

View File

@ -121,7 +121,8 @@ class ConversationBloc extends Bloc<ConversationEvent, ConversationState> {
state: s.toString().split('.').last,
jid: state.conversation!.jid,
),
awaitable: false,);
awaitable: false,
);
}
Future<void> _onInit(InitConversationEvent event, Emitter<ConversationState> emit) async {

View File

@ -15,6 +15,9 @@ import 'package:moxxyv2/ui/widgets/avatar.dart';
import 'package:moxxyv2/ui/widgets/conversation.dart';
import 'package:moxxyv2/ui/widgets/overview_menu.dart';
import 'package:moxxyv2/ui/widgets/topbar.dart';
import 'package:moxplatform/moxplatform.dart';
import 'package:moxxyv2/shared/commands.dart';
import 'package:flutter_contacts/flutter_contacts.dart';
enum ConversationsOptions {
settings
@ -127,6 +130,7 @@ class ConversationsPageState extends State<ConversationsPage> with TickerProvide
itemCount: state.conversations.length,
itemBuilder: (_context, index) {
final item = state.conversations[index];
print('${item.jid} -> ${item.contactId}');
final row = ConversationsListRow(
maxTextWidth,
item,
@ -295,7 +299,16 @@ class ConversationsPageState extends State<ConversationsPage> with TickerProvide
children: [
SpeedDialChild(
child: const Icon(Icons.group),
onTap: () => showNotImplementedDialog('groupchat', context),
onTap: () async {
final r = await FlutterContacts.requestPermission(readonly: true);
print(r);
if (!r) return;
await MoxplatformPlugin.handler.getDataSender().sendData(
GetContactsCommandDebug(),
awaitable: false,
);
},
backgroundColor: primaryColor,
// TODO(Unknown): Theme dependent?
foregroundColor: Colors.white,

View File

@ -124,6 +124,7 @@ class NewConversationPage extends StatelessWidget {
false,
false,
ChatState.gone,
contactId: item.contactId,
),
false,
showTimestamp: false,

View File

@ -1,6 +1,8 @@
import 'dart:async';
import 'dart:typed_data';
import 'package:badges/badges.dart';
import 'package:flutter/material.dart';
import 'package:flutter_contacts/flutter_contacts.dart';
import 'package:get_it/get_it.dart';
import 'package:moxxyv2/i18n/strings.g.dart';
import 'package:moxxyv2/shared/constants.dart';
@ -22,6 +24,7 @@ class ConversationsListRow extends StatefulWidget {
this.showLock = false,
this.extra,
this.avatarOnTap,
this.avatarWidget,
super.key,
}
);
@ -31,6 +34,7 @@ class ConversationsListRow extends StatefulWidget {
final bool showLock;
final bool showTimestamp;
final void Function()? avatarOnTap;
final Widget? avatarWidget;
final Widget? extra;
@override
@ -83,12 +87,17 @@ class ConversationsListRowState extends State<ConversationsListRow> {
super.dispose();
}
Widget _buildAvatar() {
final avatar = AvatarWrapper(
radius: 35,
avatarUrl: widget.conversation.avatarUrl,
altText: widget.conversation.title,
);
Widget _buildAvatar(Uint8List? data) {
final avatar = data != null ?
CircleAvatar(
radius: 35,
backgroundImage: MemoryImage(data),
) :
AvatarWrapper(
radius: 35,
avatarUrl: widget.conversation.avatarUrl,
altText: widget.conversation.title,
);
if (widget.avatarOnTap != null) {
return InkWell(
@ -191,9 +200,8 @@ class ConversationsListRowState extends State<ConversationsListRow> {
return const SizedBox();
}
@override
Widget build(BuildContext context) {
Widget _build(String title, Uint8List? avatar) {
final badgeText = widget.conversation.unreadCounter > 99 ?
'99+' :
widget.conversation.unreadCounter.toString();
@ -205,11 +213,12 @@ class ConversationsListRowState extends State<ConversationsListRow> {
final sentBySelf = widget.conversation.lastMessage?.sender == GetIt.I.get<UIDataService>().ownJid!;
final showBadge = widget.conversation.unreadCounter > 0 && !sentBySelf;
return Padding(
padding: const EdgeInsets.all(8),
child: Row(
children: [
_buildAvatar(),
_buildAvatar(avatar),
Padding(
padding: const EdgeInsets.only(left: 8),
child: LimitedBox(
@ -221,7 +230,7 @@ class ConversationsListRowState extends State<ConversationsListRow> {
mainAxisSize: MainAxisSize.min,
children: [
Text(
widget.conversation.title,
title,
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 17,
@ -294,4 +303,27 @@ class ConversationsListRowState extends State<ConversationsListRow> {
),
);
}
@override
Widget build(BuildContext context) {
if (widget.conversation.contactId != null) {
return FutureBuilder<Contact?>(
future: FlutterContacts.getContact(widget.conversation.contactId!, withThumbnail: true),
builder: (_, snapshot) {
final hasData = snapshot.hasData && snapshot.data != null;
if (hasData) {
return _build(
'${snapshot.data!.name.first} ${snapshot.data!.name.last}',
snapshot.data!.thumbnail,
);
}
return _build(widget.conversation.title, null);
}
);
}
return _build(widget.conversation.title, null);
}
}

View File

@ -407,6 +407,13 @@ packages:
url: "https://pub.dartlang.org"
source: hosted
version: "0.7.0"
flutter_contacts:
dependency: "direct main"
description:
name: flutter_contacts
url: "https://pub.dartlang.org"
source: hosted
version: "1.1.5+1"
flutter_driver:
dependency: transitive
description: flutter

View File

@ -30,6 +30,7 @@ dependencies:
sdk: flutter
flutter_bloc: 8.1.1
flutter_blurhash: 0.7.0
flutter_contacts: 1.1.5+1
flutter_image_compress: 1.1.0
flutter_isolate: 2.0.2
flutter_localizations: