diff --git a/lib/src/model/json/userdata.dart b/lib/src/model/json/userdata.dart index eb26ef3..9945264 100644 --- a/lib/src/model/json/userdata.dart +++ b/lib/src/model/json/userdata.dart @@ -108,6 +108,19 @@ class UserData { DateTime? nextTimeToShowBackupNotice; BackupServer? backupServer; TwonlySafeBackup? twonlySafeBackup; + + // For my master thesis I want to create a anonymous user study: + // - users in the "Tester" Plan can, if they want, take part of the user study + + @JsonKey(defaultValue: false) + bool askedForUserStudyPermission = false; + + // So update data can be assigned. If set the user choose to participate. + String? userStudyParticipantsToken; + + // Once a day the anonymous data is collected and send to the server + DateTime? lastUserStudyDataUpload; + Map toJson() => _$UserDataToJson(this); } diff --git a/lib/src/model/json/userdata.g.dart b/lib/src/model/json/userdata.g.dart index a478117..3dc2fbb 100644 --- a/lib/src/model/json/userdata.g.dart +++ b/lib/src/model/json/userdata.g.dart @@ -77,7 +77,14 @@ UserData _$UserDataFromJson(Map json) => UserData( ..twonlySafeBackup = json['twonlySafeBackup'] == null ? null : TwonlySafeBackup.fromJson( - json['twonlySafeBackup'] as Map); + json['twonlySafeBackup'] as Map) + ..askedForUserStudyPermission = + json['askedForUserStudyPermission'] as bool? ?? false + ..userStudyParticipantsToken = + json['userStudyParticipantsToken'] as String? + ..lastUserStudyDataUpload = json['lastUserStudyDataUpload'] == null + ? null + : DateTime.parse(json['lastUserStudyDataUpload'] as String); Map _$UserDataToJson(UserData instance) => { 'userId': instance.userId, @@ -122,6 +129,10 @@ Map _$UserDataToJson(UserData instance) => { instance.nextTimeToShowBackupNotice?.toIso8601String(), 'backupServer': instance.backupServer, 'twonlySafeBackup': instance.twonlySafeBackup, + 'askedForUserStudyPermission': instance.askedForUserStudyPermission, + 'userStudyParticipantsToken': instance.userStudyParticipantsToken, + 'lastUserStudyDataUpload': + instance.lastUserStudyDataUpload?.toIso8601String(), }; const _$ThemeModeEnumMap = { diff --git a/lib/src/services/api.service.dart b/lib/src/services/api.service.dart index 68e7489..8ba0a5c 100644 --- a/lib/src/services/api.service.dart +++ b/lib/src/services/api.service.dart @@ -41,6 +41,7 @@ import 'package:twonly/src/utils/keyvalue.dart'; import 'package:twonly/src/utils/log.dart'; import 'package:twonly/src/utils/misc.dart'; import 'package:twonly/src/utils/storage.dart'; +import 'package:twonly/src/views/user_study/user_study_data_collection.dart'; import 'package:web_socket_channel/io.dart'; final lockConnecting = Mutex(); @@ -100,6 +101,11 @@ class ApiService { unawaited(fetchGroupStatesForUnjoinedGroups()); unawaited(fetchMissingGroupPublicKey()); unawaited(checkForDeletedUsernames()); + + if (gUser.userStudyParticipantsToken != null) { + // In case the user participates in the user study, call the handler after authenticated, to be sure there is a internet connection + unawaited(handleUserStudyUpload()); + } } } diff --git a/lib/src/utils/keyvalue.dart b/lib/src/utils/keyvalue.dart index 05e5639..e52274d 100644 --- a/lib/src/utils/keyvalue.dart +++ b/lib/src/utils/keyvalue.dart @@ -4,38 +4,41 @@ import 'package:path_provider/path_provider.dart'; import 'package:twonly/src/utils/log.dart'; class KeyValueStore { - static Future _getFilePath(String key) async { + static Future _getFilePath(String key) async { final directory = await getApplicationSupportDirectory(); - return '${directory.path}/keyvalue/$key.json'; + return File('${directory.path}/keyvalue/$key.json'); + } + + static Future delete(String key) async { + try { + final file = await _getFilePath(key); + if (file.existsSync()) { + file.deleteSync(); + } + } catch (e) { + Log.error('Error deleting file: $e'); + } } static Future?> get(String key) async { try { - final filePath = await _getFilePath(key); - final file = File(filePath); - - // Check if the file exists + final file = await _getFilePath(key); if (file.existsSync()) { final contents = await file.readAsString(); return jsonDecode(contents) as Map; } else { - return null; // File does not exist + return null; } } catch (e) { - Log.error('Error reading file: $e'); + Log.warn('Error reading file: $e'); return null; } } static Future put(String key, Map value) async { try { - final filePath = await _getFilePath(key); - final file = File(filePath); - - // Create the directory if it doesn't exist + final file = await _getFilePath(key); await file.parent.create(recursive: true); - - // Write the JSON data to the file await file.writeAsString(jsonEncode(value)); } catch (e) { Log.error('Error writing file: $e'); diff --git a/lib/src/views/chats/chat_list.view.dart b/lib/src/views/chats/chat_list.view.dart index 663a088..9bcf0ed 100644 --- a/lib/src/views/chats/chat_list.view.dart +++ b/lib/src/views/chats/chat_list.view.dart @@ -25,6 +25,7 @@ import 'package:twonly/src/views/settings/help/changelog.view.dart'; import 'package:twonly/src/views/settings/profile/profile.view.dart'; import 'package:twonly/src/views/settings/settings_main.view.dart'; import 'package:twonly/src/views/settings/subscription/subscription.view.dart'; +import 'package:twonly/src/views/user_study/user_study_welcome.view.dart'; class ChatListView extends StatefulWidget { const ChatListView({super.key}); @@ -58,31 +59,49 @@ class _ChatListViewState extends State { }); }); - final changeLog = await rootBundle.loadString('CHANGELOG.md'); - final changeLogHash = - (await compute(Sha256().hash, changeLog.codeUnits)).bytes; - if (!gUser.hideChangeLog && - gUser.lastChangeLogHash.toString() != changeLogHash.toString()) { - await updateUserdata((u) { - u.lastChangeLogHash = changeLogHash; - return u; - }); - if (!mounted) return; - // only show changelog to people who already have contacts - // this prevents that this is shown directly after the user registered - if (_groupsNotPinned.isNotEmpty) { + // In case the user is already a Tester, ask him for permission. + + WidgetsBinding.instance.addPostFrameCallback((_) async { + if (gUser.subscriptionPlan == SubscriptionPlan.Tester.name && + !gUser.askedForUserStudyPermission) { await Navigator.push( context, MaterialPageRoute( builder: (context) { - return ChangeLogView( - changeLog: changeLog, + return const UserStudyWelcomeView( + wasOpenedAutomatic: true, ); }, ), ); } - } + + final changeLog = await rootBundle.loadString('CHANGELOG.md'); + final changeLogHash = + (await compute(Sha256().hash, changeLog.codeUnits)).bytes; + if (!gUser.hideChangeLog && + gUser.lastChangeLogHash.toString() != changeLogHash.toString()) { + await updateUserdata((u) { + u.lastChangeLogHash = changeLogHash; + return u; + }); + if (!mounted) return; + // only show changelog to people who already have contacts + // this prevents that this is shown directly after the user registered + if (_groupsNotPinned.isNotEmpty) { + await Navigator.push( + context, + MaterialPageRoute( + builder: (context) { + return ChangeLogView( + changeLog: changeLog, + ); + }, + ), + ); + } + } + }); } @override @@ -295,8 +314,8 @@ class _ChatListViewState extends State { child: Column( mainAxisAlignment: MainAxisAlignment.end, children: [ - FloatingActionButton.small( - backgroundColor: context.color.primary, + IconButton.filled( + color: context.color.primary, onPressed: () { Navigator.push( context, @@ -307,7 +326,7 @@ class _ChatListViewState extends State { ), ); }, - child: FaIcon( + icon: FaIcon( FontAwesomeIcons.qrcode, color: isDarkMode(context) ? Colors.black : Colors.white, ), diff --git a/lib/src/views/settings/help/help.view.dart b/lib/src/views/settings/help/help.view.dart index 5dee0ff..89b3dd2 100644 --- a/lib/src/views/settings/help/help.view.dart +++ b/lib/src/views/settings/help/help.view.dart @@ -1,3 +1,4 @@ +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:font_awesome_flutter/font_awesome_flutter.dart'; import 'package:package_info_plus/package_info_plus.dart'; @@ -10,6 +11,7 @@ import 'package:twonly/src/views/settings/help/contact_us.view.dart'; import 'package:twonly/src/views/settings/help/credits.view.dart'; import 'package:twonly/src/views/settings/help/diagnostics.view.dart'; import 'package:twonly/src/views/settings/help/faq.view.dart'; +import 'package:twonly/src/views/user_study/user_study_welcome.view.dart'; import 'package:url_launcher/url_launcher.dart'; class HelpView extends StatefulWidget { @@ -109,6 +111,21 @@ class _HelpViewState extends State { }, ), const Divider(), + if (gUser.userStudyParticipantsToken == null || kDebugMode) + ListTile( + title: const Text('Teilnahme an Nutzerstudie'), + onTap: () async { + await Navigator.push( + context, + MaterialPageRoute( + builder: (context) { + return const UserStudyWelcomeView(); + }, + ), + ); + setState(() {}); // gUser has changed + }, + ), FutureBuilder( future: PackageInfo.fromPlatform(), builder: (context, snap) { diff --git a/lib/src/views/user_study/user_study_data_collection.dart b/lib/src/views/user_study/user_study_data_collection.dart new file mode 100644 index 0000000..f4efbe3 --- /dev/null +++ b/lib/src/views/user_study/user_study_data_collection.dart @@ -0,0 +1,78 @@ +// ignore_for_file: avoid_dynamic_calls + +import 'dart:convert'; + +import 'package:http/http.dart' as http; +import 'package:twonly/globals.dart'; +import 'package:twonly/src/utils/keyvalue.dart'; +import 'package:twonly/src/utils/log.dart'; +import 'package:twonly/src/utils/storage.dart'; + +const userStudySurveyKey = 'user_study_survey'; + +// LEASE DO NOT SPAM OR TRY SENDING DIRECTLY TO THIS URL! +// You're just making my master's thesis more difficult and destroy scientific data. :/ +const surveyUrlBase = 'https://survey.twonly.org/upload.php'; + +Future handleUserStudyUpload() async { + try { + final token = gUser.userStudyParticipantsToken; + if (token == null) return; + + // in case the survey was taken offline try again + final userStudySurvey = await KeyValueStore.get(userStudySurveyKey); + if (userStudySurvey != null) { + final response = await http.post( + Uri.parse('$surveyUrlBase/create/$token'), + body: jsonEncode(userStudySurvey), + headers: {'Content-Type': 'application/json'}, + ); + if (response.statusCode != 200) { + Log.warn( + 'Got different status code for survey upload: ${response.statusCode}', + ); + return; + } + await KeyValueStore.delete(userStudySurveyKey); + } + + if (gUser.lastUserStudyDataUpload + ?.isAfter(DateTime.now().subtract(const Duration(days: 1))) ?? + false) { + // Only send updates once a day. + // This enables to see if improvements to actually work. + return; + } + + final contacts = await twonlyDB.contactsDao.getAllContacts(); + + final dataCollection = { + 'total_contacts': contacts.length, + 'accepted_contacts': contacts.where((c) => c.accepted).length, + 'verified_contacts': contacts.where((c) => c.verified).length, + }; + + final response = await http.post( + Uri.parse('$surveyUrlBase/push/$token'), + body: jsonEncode(dataCollection), + headers: {'Content-Type': 'application/json'}, + ); + if (response.statusCode == 200) { + await updateUserdata((u) { + u.lastUserStudyDataUpload = DateTime.now(); + return u; + }); + } + if (response.statusCode == 404) { + // Token is unknown to the server... + await updateUserdata((u) { + u + ..lastUserStudyDataUpload = null + ..userStudyParticipantsToken = null; + return u; + }); + } + } catch (e) { + Log.error(e); + } +} diff --git a/lib/src/views/user_study/user_study_questionnaire.view.dart b/lib/src/views/user_study/user_study_questionnaire.view.dart new file mode 100644 index 0000000..a0cb9e6 --- /dev/null +++ b/lib/src/views/user_study/user_study_questionnaire.view.dart @@ -0,0 +1,265 @@ +// ignore_for_file: avoid_dynamic_calls + +import 'package:flutter/material.dart'; +import 'package:twonly/src/utils/keyvalue.dart'; +import 'package:twonly/src/utils/misc.dart'; +import 'package:twonly/src/utils/storage.dart'; +import 'package:twonly/src/views/user_study/user_study_data_collection.dart'; + +class UserStudyQuestionnaire extends StatefulWidget { + const UserStudyQuestionnaire({super.key}); + + @override + State createState() => _UserStudyQuestionnaireState(); +} + +class _UserStudyQuestionnaireState extends State { + final Map _responses = { + 'gender': null, + 'gender_free': '', + 'age': null, + 'education': null, + 'education_free': '', + 'vocational': null, + 'vocational_free': '', + 'enrolled': null, + 'study_program': '', + 'working': null, + 'work_field': '', + 'smartphone_2years': null, + 'comp_knowledge': null, + 'security_knowledge': null, + 'messengers': [], + }; + + final List _messengerOptions = [ + 'WhatsApp', + 'Signal', + 'Telegram', + 'Facebook Messenger', + 'iMessage', + 'Teams', + 'Viber', + 'Element', + 'Andere', + ]; + + Future _submitData() async { + await KeyValueStore.put(userStudySurveyKey, _responses); + + await updateUserdata((u) { + // generate a random participants id to identify data send later while keeping the user anonym + u.userStudyParticipantsToken = getRandomString(25); + return u; + }); + + await handleUserStudyUpload(); + + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Vielen Dank für deine Teilnahme!')), + ); + + Navigator.pop(context); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('Befragung')), + body: SingleChildScrollView( + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _sectionTitle('Demografische Daten'), + _questionText('Was ist dein Geschlecht?'), + _buildRadioList( + ['Männlich', 'Weiblich', 'Divers', 'Keine Angabe'], + 'gender', + ), + _buildTextField( + 'Freitext (optional)', + (val) => _responses['gender_free'] = val, + ), + _questionText('Wie alt bist du?'), + _buildRadioList( + [ + '18-22', + '23-27', + '28-32', + '33-37', + '38-42', + '43-47', + '48-52', + '53-57', + '58-62', + '63-67', + '68-72', + '73-77', + '77 oder älter', + 'Keine Angabe', + ], + 'age', + ), + _questionText('Was ist dein höchster Schulabschluss?'), + _buildRadioList( + [ + 'Noch in der Schule', + 'Hauptschulabschluss', + 'POS (Polytechnische Oberschule)', + 'Realschulabschluss', + 'Abitur / Hochschulreife', + 'Kein Abschluss', + 'Keine Angabe', + ], + 'education', + ), + _buildTextField( + 'Freitext (optional)', + (val) => _responses['education_free'] = val, + ), + _questionText('Was ist dein höchster beruflicher Abschluss?'), + _buildRadioList( + [ + 'Berufsausbildung / Duales System', + 'Fachschulabschluss', + 'Fachschulabschluss (ehem. DDR)', + 'Bachelor', + 'Master', + 'Diplom', + 'Promotion (PhD)', + 'Kein beruflicher Abschluss', + 'Keine Angabe', + ], + 'vocational', + ), + _buildTextField( + 'Freitext (optional)', + (val) => _responses['vocational_free'] = val, + ), + _questionText( + 'Bist du derzeit in einem Studiengang eingeschrieben? (Bachelor, Master, Diplom, Staatsexamen, Magister)', + ), + _buildRadioList(['Ja', 'Nein', 'Keine Angabe'], 'enrolled'), + _questionText('Wenn ja, welcher Studiengang?'), + _buildTextField( + 'Studiengang eingeben', + (val) => _responses['study_program'] = val, + ), + _questionText('Bist du derzeit berufstätig?'), + _buildRadioList(['Ja', 'Nein', 'Keine Angabe'], 'working'), + _questionText('Wenn ja, in welchem Bereich arbeiten Sie?'), + _buildTextField( + 'Arbeitsbereich eingeben', + (val) => _responses['work_field'] = val, + ), + const SizedBox(height: 30), + // const Divider(), + _sectionTitle('Technisches Wissen'), + _questionText( + 'Nutzt du seit mehr als zwei Jahren ein Smartphone?', + ), + _buildRadioList(['Ja', 'Nein'], 'smartphone_2years'), + _questionText( + 'Wie schätzt du deine allgemeinen Computerkenntnisse ein?', + ), + _buildRadioList( + ['Anfänger', 'Mittel', 'Fortgeschritten'], + 'comp_knowledge', + ), + _questionText( + 'Wie schätzt du dein Wissen im Bereich IT-Sicherheit ein?', + ), + _buildRadioList( + ['Anfänger', 'Mittel', 'Fortgeschritten'], + 'security_knowledge', + ), + _questionText( + 'Welche der folgenden Messenger hast du schon einmal benutzt?', + ), + ..._messengerOptions.map( + (m) => CheckboxListTile( + title: Text(m), + visualDensity: const VisualDensity(horizontal: 0, vertical: -4), + value: (_responses['messengers'] as List).contains(m), + onChanged: (bool? value) { + setState(() { + value! + ? _responses['messengers'].add(m) + : _responses['messengers'].remove(m); + }); + }, + ), + ), + const SizedBox(height: 30), + Center( + child: FilledButton( + onPressed: _submitData, + child: const Padding( + padding: EdgeInsets.symmetric(horizontal: 40, vertical: 15), + child: + Text('Jetzt teilnehmen', style: TextStyle(fontSize: 18)), + ), + ), + ), + const SizedBox(height: 50), + ], + ), + ), + ); + } + + // Hilfsmethoden für das UI + Widget _sectionTitle(String title) => Padding( + padding: const EdgeInsets.symmetric(vertical: 10), + child: Text( + title, + style: const TextStyle( + fontSize: 24, + fontWeight: FontWeight.bold, + ), + ), + ); + + Widget _questionText(String text) => Padding( + padding: const EdgeInsets.only(top: 20, bottom: 5), + child: Text( + text, + style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600), + ), + ); + + Widget _buildRadioList(List options, String key) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 8.0), + child: DropdownButtonFormField( + decoration: const InputDecoration( + border: OutlineInputBorder(), + contentPadding: EdgeInsets.symmetric(horizontal: 12, vertical: 8), + labelText: 'Bitte wählen...', + ), + initialValue: _responses[key] as String?, + items: options.map((String value) { + return DropdownMenuItem( + value: value, + child: Text(value), + ); + }).toList(), + onChanged: (val) { + setState(() { + _responses[key] = val; + }); + }, + ), + ); + } + + Widget _buildTextField(String hint, void Function(String) onChanged) { + return TextField( + decoration: + InputDecoration(hintText: hint, border: const OutlineInputBorder()), + onChanged: onChanged, + ); + } +} diff --git a/lib/src/views/user_study/user_study_welcome.view.dart b/lib/src/views/user_study/user_study_welcome.view.dart new file mode 100644 index 0000000..ed8adb1 --- /dev/null +++ b/lib/src/views/user_study/user_study_welcome.view.dart @@ -0,0 +1,120 @@ +import 'package:flutter/material.dart'; +import 'package:twonly/src/utils/storage.dart'; +import 'package:twonly/src/views/user_study/user_study_questionnaire.view.dart'; + +class UserStudyWelcomeView extends StatefulWidget { + const UserStudyWelcomeView({super.key, this.wasOpenedAutomatic = false}); + + final bool wasOpenedAutomatic; + + @override + State createState() => _UserStudyWelcomeViewState(); +} + +class _UserStudyWelcomeViewState extends State { + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text('Teilnahme an Nutzerstudie'), + ), + body: Padding( + padding: const EdgeInsets.all(12), + child: ListView( + children: [ + const SizedBox(height: 30), + const Text( + 'Es dauert nur ein paar Minuten.', + textAlign: TextAlign.center, + style: TextStyle( + fontWeight: FontWeight.bold, + fontSize: 20, + ), + ), + const SizedBox(height: 20), + const Text( + 'Im Rahmen meiner Masterarbeit möchte ich die Benutzerfreundlichkeit von anonymen und dezentralen Messenger-Diensten verbessern.', + textAlign: TextAlign.center, + ), + const SizedBox(height: 10), + const Text( + 'Zu diesem Zweck werden in den nächsten Monaten verschiedene Änderungen an der App vorgenommen. Um die Wirksamkeit der Änderungen zu messen, möchte ich einige Daten über deine Nutzung der App sammeln sowie eine kurze Befragung durchführen.', + textAlign: TextAlign.center, + ), + const SizedBox(height: 10), + const Text( + 'Die Daten bestehen ausschließlich aus Zahlen, z. B. Anzahl deiner Kontakte. Alle Daten werden anonym übermittelt und können nicht mit deinem Benutzerkonto verknüpft werden.', + textAlign: TextAlign.center, + ), + const SizedBox(height: 10), + const Text( + 'Die Masterarbeit und damit die Nutzerstudie wird bis September durchgeführt. Nach Abschluss erhältst du eine Benachrichtigung und wirst über die Ergebnisse informiert.', + textAlign: TextAlign.center, + ), + const SizedBox(height: 40), + Center( + child: FilledButton( + onPressed: () { + Navigator.pushReplacement( + context, + MaterialPageRoute( + builder: (context) { + return const UserStudyQuestionnaire(); + }, + ), + ); + }, + child: const Padding( + padding: EdgeInsets.symmetric(horizontal: 30, vertical: 15), + child: Text( + 'Weiter zur Befragung', + style: TextStyle(fontSize: 18), + ), + ), + ), + ), + const SizedBox(height: 10), + if (widget.wasOpenedAutomatic) + Center( + child: OutlinedButton( + onPressed: () { + Navigator.pop(context); + }, + child: const Padding( + padding: EdgeInsets.symmetric(horizontal: 20, vertical: 10), + child: Text( + 'Frag mich später noch mal', + style: TextStyle(fontSize: 18), + ), + ), + ), + ), + const SizedBox(height: 10), + if (widget.wasOpenedAutomatic) + Center( + child: GestureDetector( + onTap: () async { + await updateUserdata((u) { + u.askedForUserStudyPermission = true; + return u; + }); + if (context.mounted) Navigator.pop(context); + }, + child: const Text( + 'Nicht mehr anzeigen', + style: TextStyle(fontSize: 12), + ), + ), + ), + const SizedBox(height: 10), + const Text( + 'PS: twonly ist Open Source, wenn du also genau wissen willst, welche Daten übertragen werden, schau dir einfach die Datei "lib/src/views/user_study/user_study_data_collection.dart" im Repository an :).', + textAlign: TextAlign.center, + style: TextStyle(fontSize: 10), + ), + ], + ), + ), + ); + } +}