#2 feature/profile-page

Merged
tovi merged 3 commits from feature/profile-page into develop 2 years ago
  1. +52
    -0
      Backend/Api/Auth/ChangeMessageExpiry.go
  2. +76
    -0
      Backend/Api/Auth/ChangePassword.go
  3. +1
    -0
      Backend/Api/Auth/Check.go
  4. +17
    -8
      Backend/Api/Auth/Login.go
  5. +5
    -4
      Backend/Api/Auth/Signup.go
  6. +3
    -0
      Backend/Api/Routes.go
  7. +14
    -8
      Backend/Database/Init.go
  8. +1
    -1
      Backend/Database/Seeder/MessageSeeder.go
  9. +1
    -0
      Backend/Database/Seeder/Seed.go
  10. +10
    -6
      Backend/Models/Messages.go
  11. +46
    -6
      Backend/Models/Users.go
  12. +7
    -1
      mobile/lib/components/custom_title_bar.dart
  13. +2
    -3
      mobile/lib/components/qr_reader.dart
  14. +108
    -0
      mobile/lib/components/select_message_ttl.dart
  15. +1
    -1
      mobile/lib/components/user_search_result.dart
  16. +2
    -0
      mobile/lib/main.dart
  17. +30
    -2
      mobile/lib/models/my_profile.dart
  18. +5
    -6
      mobile/lib/utils/storage/conversations.dart
  19. +1
    -2
      mobile/lib/utils/storage/friends.dart
  20. +3
    -4
      mobile/lib/utils/storage/messages.dart
  21. +148
    -143
      mobile/lib/views/authentication/login.dart
  22. +230
    -162
      mobile/lib/views/authentication/signup.dart
  23. +5
    -1
      mobile/lib/views/main/conversation/detail.dart
  24. +1
    -1
      mobile/lib/views/main/conversation/list.dart
  25. +57
    -56
      mobile/lib/views/main/conversation/list_item.dart
  26. +2
    -3
      mobile/lib/views/main/friend/add_search.dart
  27. +5
    -6
      mobile/lib/views/main/friend/request_list_item.dart
  28. +9
    -8
      mobile/lib/views/main/home.dart
  29. +180
    -0
      mobile/lib/views/main/profile/change_password.dart
  30. +142
    -0
      mobile/lib/views/main/profile/change_server_url.dart
  31. +215
    -94
      mobile/lib/views/main/profile/profile.dart
  32. +7
    -0
      mobile/pubspec.lock
  33. +1
    -0
      mobile/pubspec.yaml

+ 52
- 0
Backend/Api/Auth/ChangeMessageExpiry.go View File

@ -0,0 +1,52 @@
package Auth
import (
"encoding/json"
"io/ioutil"
"net/http"
"git.tovijaeschke.xyz/tovi/Envelope/Backend/Database"
"git.tovijaeschke.xyz/tovi/Envelope/Backend/Models"
)
type rawChangeMessageExpiry struct {
MessageExpiry string `json:"message_exipry"`
}
// ChangeMessageExpiry handles changing default message expiry for user
func ChangeMessageExpiry(w http.ResponseWriter, r *http.Request) {
var (
user Models.User
changeMessageExpiry rawChangeMessageExpiry
requestBody []byte
err error
)
// Ignore error here, as middleware should handle auth
user, _ = CheckCookieCurrentUser(w, r)
requestBody, err = ioutil.ReadAll(r.Body)
if err != nil {
http.Error(w, "Error", http.StatusInternalServerError)
return
}
err = json.Unmarshal(requestBody, &changeMessageExpiry)
if err != nil {
http.Error(w, "Error", http.StatusInternalServerError)
return
}
user.AsymmetricPrivateKey = changeMessageExpiry.MessageExpiry
err = Database.UpdateUser(
user.ID.String(),
&user,
)
if err != nil {
http.Error(w, "Error", http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusOK)
}

+ 76
- 0
Backend/Api/Auth/ChangePassword.go View File

@ -0,0 +1,76 @@
package Auth
import (
"encoding/json"
"io/ioutil"
"net/http"
"git.tovijaeschke.xyz/tovi/Envelope/Backend/Database"
"git.tovijaeschke.xyz/tovi/Envelope/Backend/Models"
)
type rawChangePassword struct {
OldPassword string `json:"old_password"`
NewPassword string `json:"new_password"`
NewPasswordConfirm string `json:"new_password_confirm"`
PrivateKey string `json:"private_key"`
}
// ChangePassword handle change password action
func ChangePassword(w http.ResponseWriter, r *http.Request) {
var (
user Models.User
changePassword rawChangePassword
requestBody []byte
err error
)
user, err = CheckCookieCurrentUser(w, r)
if err != nil {
// Don't bother showing an error here, as the middleware handles auth
return
}
requestBody, err = ioutil.ReadAll(r.Body)
if err != nil {
http.Error(w, "Error", http.StatusInternalServerError)
return
}
err = json.Unmarshal(requestBody, &changePassword)
if err != nil {
http.Error(w, "Error", http.StatusInternalServerError)
return
}
if !CheckPasswordHash(changePassword.OldPassword, user.Password) {
http.Error(w, "Invalid Current Password", http.StatusForbidden)
return
}
// This should never occur, due to frontend validation
if changePassword.NewPassword != changePassword.NewPasswordConfirm {
http.Error(w, "Invalid New Password", http.StatusUnprocessableEntity)
return
}
user.Password, err = HashPassword(changePassword.NewPassword)
if err != nil {
http.Error(w, "Error", http.StatusInternalServerError)
return
}
// Private key doesn't change at this point, is just re-encrypted with the new password
user.AsymmetricPrivateKey = changePassword.PrivateKey
err = Database.UpdateUser(
user.ID.String(),
&user,
)
if err != nil {
http.Error(w, "Error", http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusOK)
}

+ 1
- 0
Backend/Api/Auth/Check.go View File

@ -4,6 +4,7 @@ import (
"net/http"
)
// Check is used to check session viability
func Check(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
}

+ 17
- 8
Backend/Api/Auth/Login.go View File

@ -1,6 +1,7 @@
package Auth
import (
"database/sql/driver"
"encoding/json"
"net/http"
"time"
@ -9,7 +10,7 @@ import (
"git.tovijaeschke.xyz/tovi/Envelope/Backend/Models"
)
type Credentials struct {
type credentials struct {
Username string `json:"username"`
Password string `json:"password"`
}
@ -21,25 +22,32 @@ type loginResponse struct {
AsymmetricPrivateKey string `json:"asymmetric_private_key"`
UserID string `json:"user_id"`
Username string `json:"username"`
MessageExpiryDefault string `json:"message_expiry_default"`
}
func makeLoginResponse(w http.ResponseWriter, code int, message, pubKey, privKey string, user Models.User) {
var (
status string = "error"
returnJson []byte
err error
status = "error"
messageExpiryRaw driver.Value
messageExpiry string
returnJSON []byte
err error
)
if code > 200 && code < 300 {
if code >= 200 && code <= 300 {
status = "success"
}
returnJson, err = json.MarshalIndent(loginResponse{
messageExpiryRaw, _ = user.MessageExpiryDefault.Value()
messageExpiry, _ = messageExpiryRaw.(string)
returnJSON, err = json.MarshalIndent(loginResponse{
Status: status,
Message: message,
AsymmetricPublicKey: pubKey,
AsymmetricPrivateKey: privKey,
UserID: user.ID.String(),
Username: user.Username,
MessageExpiryDefault: messageExpiry,
}, "", " ")
if err != nil {
http.Error(w, "Error", http.StatusInternalServerError)
@ -48,12 +56,13 @@ func makeLoginResponse(w http.ResponseWriter, code int, message, pubKey, privKey
// Return updated json
w.WriteHeader(code)
w.Write(returnJson)
w.Write(returnJSON)
}
// Login logs the user into the system
func Login(w http.ResponseWriter, r *http.Request) {
var (
creds Credentials
creds credentials
userData Models.User
session Models.Session
expiresAt time.Time


+ 5
- 4
Backend/Api/Auth/Signup.go View File

@ -18,15 +18,15 @@ type signupResponse struct {
func makeSignupResponse(w http.ResponseWriter, code int, message string) {
var (
status string = "error"
returnJson []byte
status = "error"
returnJSON []byte
err error
)
if code > 200 && code < 300 {
status = "success"
}
returnJson, err = json.MarshalIndent(signupResponse{
returnJSON, err = json.MarshalIndent(signupResponse{
Status: status,
Message: message,
}, "", " ")
@ -37,10 +37,11 @@ func makeSignupResponse(w http.ResponseWriter, code int, message string) {
// Return updated json
w.WriteHeader(code)
w.Write(returnJson)
w.Write(returnJSON)
}
// Signup to the platform
func Signup(w http.ResponseWriter, r *http.Request) {
var (
userData Models.User


+ 3
- 0
Backend/Api/Routes.go View File

@ -61,6 +61,9 @@ func InitAPIEndpoints(router *mux.Router) {
authAPI.HandleFunc("/check", Auth.Check).Methods("GET")
authAPI.HandleFunc("/change_password", Auth.ChangePassword).Methods("POST")
authAPI.HandleFunc("/message_expiry", Auth.ChangeMessageExpiry).Methods("POST")
authAPI.HandleFunc("/users", Users.SearchUsers).Methods("GET")
authAPI.HandleFunc("/friend_requests", Friends.EncryptedFriendRequestList).Methods("GET")


+ 14
- 8
Backend/Database/Init.go View File

@ -10,13 +10,14 @@ import (
)
const (
dbUrl = "postgres://postgres:@localhost:5432/envelope"
dbTestUrl = "postgres://postgres:@localhost:5432/envelope_test"
dbURL = "postgres://postgres:@localhost:5432/envelope"
dbTestURL = "postgres://postgres:@localhost:5432/envelope_test"
)
// DB db
var DB *gorm.DB
func GetModels() []interface{} {
func getModels() []interface{} {
return []interface{}{
&Models.Session{},
&Models.User{},
@ -29,6 +30,7 @@ func GetModels() []interface{} {
}
}
// Init initializes the database connection
func Init() {
var (
model interface{}
@ -37,7 +39,7 @@ func Init() {
log.Println("Initializing database...")
DB, err = gorm.Open(postgres.Open(dbUrl), &gorm.Config{})
DB, err = gorm.Open(postgres.Open(dbURL), &gorm.Config{})
if err != nil {
log.Fatalln(err)
@ -45,24 +47,28 @@ func Init() {
log.Println("Running AutoMigrate...")
for _, model = range GetModels() {
DB.AutoMigrate(model)
for _, model = range getModels() {
err = DB.AutoMigrate(model)
if err != nil {
log.Fatalln(err)
}
}
}
// InitTest initializes the test datbase
func InitTest() {
var (
model interface{}
err error
)
DB, err = gorm.Open(postgres.Open(dbTestUrl), &gorm.Config{})
DB, err = gorm.Open(postgres.Open(dbTestURL), &gorm.Config{})
if err != nil {
log.Fatalln(err)
}
for _, model = range GetModels() {
for _, model = range getModels() {
DB.Migrator().DropTable(model)
DB.AutoMigrate(model)
}


+ 1
- 1
Backend/Database/Seeder/MessageSeeder.go View File

@ -298,7 +298,7 @@ func SeedMessages() {
panic(err)
}
for i = 0; i <= 20; i++ {
for i = 0; i <= 100; i++ {
err = seedMessage(
primaryUser,
secondaryUser,


+ 1
- 0
Backend/Database/Seeder/Seed.go View File

@ -58,6 +58,7 @@ var (
decodedPrivateKey *rsa.PrivateKey
)
// Seed seeds semi random data for use in testing & development
func Seed() {
var (
block *pem.Block


+ 10
- 6
Backend/Models/Messages.go View File

@ -1,12 +1,14 @@
package Models
import (
"database/sql"
"time"
"github.com/gofrs/uuid"
)
// TODO: Add support for images
// MessageData holds the content of the message
// encrypted through the Message.SymmetricKey
type MessageData struct {
Base
Data string `gorm:"not null" json:"data"` // Stored encrypted
@ -14,11 +16,13 @@ type MessageData struct {
SymmetricKey string `gorm:"not null" json:"symmetric_key"` // Stored encrypted
}
// Message holds data pertaining to each users' message
type Message struct {
Base
MessageDataID uuid.UUID `json:"message_data_id"`
MessageData MessageData `json:"message_data"`
SymmetricKey string `json:"symmetric_key" gorm:"not null"` // Stored encrypted
AssociationKey string `json:"association_key" gorm:"not null"` // TODO: This links all encrypted messages for a user in a thread together. Find a way to fix this
CreatedAt time.Time `json:"created_at" gorm:"not null"`
MessageDataID uuid.UUID ` json:"message_data_id"`
MessageData MessageData ` json:"message_data"`
SymmetricKey string `gorm:"not null" json:"symmetric_key"` // Stored encrypted
AssociationKey string `gorm:"not null" json:"association_key"` // Stored encrypted
Expiry sql.NullTime ` json:"expiry"`
CreatedAt time.Time `gorm:"not null" json:"created_at"`
}

+ 46
- 6
Backend/Models/Users.go View File

@ -1,10 +1,12 @@
package Models
import (
"database/sql/driver"
"gorm.io/gorm"
)
// Prevent updating the email if it has not changed
// BeforeUpdate prevents updating the email if it has not changed
// This stops a unique constraint error
func (u *User) BeforeUpdate(tx *gorm.DB) (err error) {
if !tx.Statement.Changed("Username") {
@ -13,11 +15,49 @@ func (u *User) BeforeUpdate(tx *gorm.DB) (err error) {
return nil
}
// MessageExpiry holds values for how long messages should expire by default
type MessageExpiry []uint8
const (
// MessageExpiryFifteenMin expires after 15 minutes
MessageExpiryFifteenMin = "fifteen_min"
// MessageExpiryThirtyMin expires after 30 minutes
MessageExpiryThirtyMin = "thirty_min"
// MessageExpiryOneHour expires after one hour
MessageExpiryOneHour = "one_hour"
// MessageExpiryThreeHour expires after three hours
MessageExpiryThreeHour = "three_hour"
// MessageExpirySixHour expires after six hours
MessageExpirySixHour = "six_hour"
// MessageExpiryTwelveHour expires after twelve hours
MessageExpiryTwelveHour = "twelve_hour"
// MessageExpiryOneDay expires after one day
MessageExpiryOneDay = "one_day"
// MessageExpiryThreeDay expires after three days
MessageExpiryThreeDay = "three_day"
// MessageExpiryNoExpiry never expires
MessageExpiryNoExpiry = "no_expiry"
)
// Scan new value into MessageExpiry
func (e *MessageExpiry) Scan(value interface{}) error {
*e = MessageExpiry(value.(string))
return nil
}
// Value gets value out of MessageExpiry column
func (e MessageExpiry) Value() (driver.Value, error) {
return string(e), nil
}
// User holds user data
type User struct {
Base
Username string `gorm:"not null;unique" json:"username"`
Password string `gorm:"not null" json:"password"`
ConfirmPassword string `gorm:"-" json:"confirm_password"`
AsymmetricPrivateKey string `gorm:"not null" json:"asymmetric_private_key"` // Stored encrypted
AsymmetricPublicKey string `gorm:"not null" json:"asymmetric_public_key"`
Username string `gorm:"not null;unique" json:"username"`
Password string `gorm:"not null" json:"password"`
ConfirmPassword string `gorm:"-" json:"confirm_password"`
AsymmetricPrivateKey string `gorm:"not null" json:"asymmetric_private_key"` // Stored encrypted
AsymmetricPublicKey string `gorm:"not null" json:"asymmetric_public_key"`
MessageExpiryDefault MessageExpiry `gorm:"default:no_expiry" json:"-" sql:"type:ENUM('fifteen_min', 'thirty_min', 'one_hour', 'three_hour', 'six_hour', 'twelve_hour', 'one_day', 'three_day')"` // Stored encrypted
}

+ 7
- 1
mobile/lib/components/custom_title_bar.dart View File

@ -8,12 +8,14 @@ class CustomTitleBar extends StatelessWidget with PreferredSizeWidget {
required this.showBack,
this.rightHandButton,
this.backgroundColor,
this.beforeBack,
}) : super(key: key);
final Text title;
final bool showBack;
final IconButton? rightHandButton;
final Color? backgroundColor;
final Future<void> Function()? beforeBack;
@override
Size get preferredSize => const Size.fromHeight(kToolbarHeight);
@ -59,7 +61,11 @@ class CustomTitleBar extends StatelessWidget with PreferredSizeWidget {
Widget _backButton(BuildContext context) {
return IconButton(
onPressed: (){
onPressed: () {
if (beforeBack != null) {
beforeBack!().then((dynamic) => Navigator.pop(context));
return;
}
Navigator.pop(context);
},
icon: Icon(


+ 2
- 3
mobile/lib/components/qr_reader.dart View File

@ -1,9 +1,7 @@
import 'dart:convert';
import 'dart:io';
import 'package:Envelope/utils/storage/session_cookie.dart';
import 'package:flutter/material.dart';
import 'package:flutter_dotenv/flutter_dotenv.dart';
import 'package:pointycastle/impl.dart';
import 'package:qr_code_scanner/qr_code_scanner.dart';
import 'package:sqflite/sqflite.dart';
@ -16,6 +14,7 @@ import '/utils/encryption/aes_helper.dart';
import '/utils/encryption/crypto_utils.dart';
import '/utils/storage/database.dart';
import '/utils/strings.dart';
import '/utils/storage/session_cookie.dart';
import 'flash_message.dart';
class QrReader extends StatefulWidget {
@ -128,7 +127,7 @@ class _QrReaderState extends State<QrReader> {
]);
var resp = await http.post(
Uri.parse('${dotenv.env["SERVER_URL"]}api/v1/auth/friend_request/qr_code'),
await MyProfile.getServerUrl('api/v1/auth/friend_request/qr_code'),
headers: <String, String>{
'Content-Type': 'application/json; charset=UTF-8',
'cookie': await getSessionCookie(),


+ 108
- 0
mobile/lib/components/select_message_ttl.dart View File

@ -0,0 +1,108 @@
import 'package:flutter/material.dart';
import '/components/custom_title_bar.dart';
const Map<String, String> messageExpiryValues = {
'no_expiry': 'No Expiry',
'fifteen_min': '15 Minutes',
'thirty_min': '30 Minutes',
'one_hour': '1 Hour',
'three_hour': '3 Hours',
'six_hour': '6 Hours',
'twelve_day': '12 Hours',
'one_day': '1 Day',
'three_day': '3 Days',
};
class SelectMessageTTL extends StatefulWidget {
const SelectMessageTTL({
Key? key,
required this.widgetTitle,
required this.backCallback,
this.currentSelected,
}) : super(key: key);
final String widgetTitle;
final Future<void> Function(String messageExpiry) backCallback;
final String? currentSelected;
@override
_SelectMessageTTLState createState() => _SelectMessageTTLState();
}
class _SelectMessageTTLState extends State<SelectMessageTTL> {
String selectedExpiry = 'no_expiry';
@override
void initState() {
selectedExpiry = widget.currentSelected ?? 'no_expiry';
super.initState();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: CustomTitleBar(
title: Text(
widget.widgetTitle,
style: const TextStyle(
fontSize: 28,
fontWeight: FontWeight.bold
)
),
showBack: true,
backgroundColor: Colors.transparent,
beforeBack: () async {
widget.backCallback(selectedExpiry);
},
),
body: Padding(
padding: const EdgeInsets.only(top: 30),
child: list(),
),
);
}
Widget list() {
return ListView.builder(
itemCount: messageExpiryValues.length,
shrinkWrap: true,
itemBuilder: (context, i) {
String key = messageExpiryValues.keys.elementAt(i);
return GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () {
setState(() {
selectedExpiry = key;
});
},
child: Padding(
padding: const EdgeInsets.only(left: 30, right: 20, top: 8, bottom: 8),
child: Row(
children: [
selectedExpiry == key ?
const Icon(Icons.check) :
const SizedBox(width: 20),
const SizedBox(width: 16),
Expanded(
child: Align(
alignment: Alignment.centerLeft,
child: Text(
messageExpiryValues[key] ?? '',
style: const TextStyle(
fontSize: 20,
fontWeight: FontWeight.normal,
),
),
)
)
],
)
)
);
},
);
}
}

+ 1
- 1
mobile/lib/components/user_search_result.dart View File

@ -119,7 +119,7 @@ class _UserSearchResultState extends State<UserSearchResult>{
});
var resp = await http.post(
Uri.parse('${dotenv.env["SERVER_URL"]}api/v1/auth/friend_request'),
await MyProfile.getServerUrl('api/v1/auth/friend_request'),
headers: {
'cookie': await getSessionCookie(),
},


+ 2
- 0
mobile/lib/main.dart View File

@ -49,6 +49,8 @@ class MyApp extends StatelessWidget {
brightness: Brightness.dark,
primaryColor: Colors.orange.shade900,
backgroundColor: Colors.grey.shade800,
scaffoldBackgroundColor: Colors.grey[850],
disabledColor: Colors.grey[400],
colorScheme: ColorScheme(
brightness: Brightness.dark,
primary: Colors.orange.shade900,


+ 30
- 2
mobile/lib/models/my_profile.dart View File

@ -1,9 +1,16 @@
import 'dart:convert';
import 'package:Envelope/utils/encryption/aes_helper.dart';
import 'package:Envelope/utils/encryption/crypto_utils.dart';
import 'package:Envelope/components/select_message_ttl.dart';
import 'package:flutter_dotenv/flutter_dotenv.dart';
import 'package:pointycastle/impl.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '/utils/encryption/aes_helper.dart';
import '/utils/encryption/crypto_utils.dart';
// TODO: Replace this with the prod url when server is deployed
String defaultServerUrl = dotenv.env['SERVER_URL'] ?? 'http://192.168.1.5:8080';
class MyProfile {
String id;
String username;
@ -11,6 +18,7 @@ class MyProfile {
RSAPrivateKey? privateKey;
RSAPublicKey? publicKey;
DateTime? loggedInAt;
String messageExpiryDefault = 'no_expiry';
MyProfile({
required this.id,
@ -19,6 +27,7 @@ class MyProfile {
this.privateKey,
this.publicKey,
this.loggedInAt,
required this.messageExpiryDefault,
});
factory MyProfile._fromJson(Map<String, dynamic> json) {
@ -36,6 +45,7 @@ class MyProfile {
privateKey: privateKey,
publicKey: publicKey,
loggedInAt: loggedInAt,
messageExpiryDefault: json['message_expiry_default']
);
}
@ -61,6 +71,7 @@ class MyProfile {
CryptoUtils.encodeRSAPublicKeyToPem(publicKey!) :
null,
'logged_in_at': loggedInAt?.toIso8601String(),
'message_expiry_default': messageExpiryDefault,
});
}
@ -107,5 +118,22 @@ class MyProfile {
}
return profile.privateKey!;
}
static setServerUrl(String url) async {
final preferences = await SharedPreferences.getInstance();
preferences.setString('server_url', url);
}
static Future<Uri> getServerUrl(String path) async {
final preferences = await SharedPreferences.getInstance();
String? baseUrl = preferences.getString('server_url');
if (baseUrl == null) {
setServerUrl(defaultServerUrl);
return Uri.parse('$defaultServerUrl$path');
}
return Uri.parse('$baseUrl$path');
}
}

+ 5
- 6
mobile/lib/utils/storage/conversations.dart View File

@ -1,12 +1,11 @@
import 'dart:convert';
import 'package:Envelope/components/flash_message.dart';
import 'package:flutter/material.dart';
import 'package:flutter_dotenv/flutter_dotenv.dart';
import 'package:http/http.dart' as http;
import 'package:pointycastle/export.dart';
import 'package:sqflite/sqflite.dart';
import '/components/flash_message.dart';
import '/models/conversation_users.dart';
import '/models/conversations.dart';
import '/models/my_profile.dart';
@ -20,7 +19,7 @@ Future<void> updateConversation(Conversation conversation, { includeUsers = true
Map<String, dynamic> conversationJson = await conversation.payloadJson(includeUsers: includeUsers);
var x = await http.put(
Uri.parse('${dotenv.env["SERVER_URL"]}api/v1/auth/conversations'),
await MyProfile.getServerUrl('api/v1/auth/conversations'),
headers: <String, String>{
'Content-Type': 'application/json; charset=UTF-8',
'cookie': sessionCookie,
@ -38,7 +37,7 @@ Future<void> updateConversations() async {
// try {
var resp = await http.get(
Uri.parse('${dotenv.env["SERVER_URL"]}api/v1/auth/conversations'),
await MyProfile.getServerUrl('api/v1/auth/conversations'),
headers: {
'cookie': await getSessionCookie(),
}
@ -68,7 +67,7 @@ Future<void> updateConversations() async {
Map<String, String> params = {};
params['conversation_detail_ids'] = conversationsDetailIds.join(',');
var uri = Uri.parse('${dotenv.env["SERVER_URL"]}api/v1/auth/conversation_details');
var uri = await MyProfile.getServerUrl('api/v1/auth/conversation_details');
uri = uri.replace(queryParameters: params);
resp = await http.get(
@ -150,7 +149,7 @@ Future<void> uploadConversation(Conversation conversation, BuildContext context)
Map<String, dynamic> conversationJson = await conversation.payloadJson();
var resp = await http.post(
Uri.parse('${dotenv.env["SERVER_URL"]}api/v1/auth/conversations'),
await MyProfile.getServerUrl('api/v1/auth/conversations'),
headers: <String, String>{
'Content-Type': 'application/json; charset=UTF-8',
'cookie': sessionCookie,


+ 1
- 2
mobile/lib/utils/storage/friends.dart View File

@ -1,6 +1,5 @@
import 'dart:convert';
import 'package:flutter_dotenv/flutter_dotenv.dart';
import 'package:http/http.dart' as http;
import 'package:pointycastle/export.dart';
import 'package:sqflite/sqflite.dart';
@ -15,7 +14,7 @@ Future<void> updateFriends() async {
// try {
var resp = await http.get(
Uri.parse('${dotenv.env["SERVER_URL"]}api/v1/auth/friend_requests'),
await MyProfile.getServerUrl('api/v1/auth/friend_requests'),
headers: {
'cookie': await getSessionCookie(),
}


+ 3
- 4
mobile/lib/utils/storage/messages.dart View File

@ -1,6 +1,5 @@
import 'dart:convert';
import 'package:flutter_dotenv/flutter_dotenv.dart';
import 'package:http/http.dart' as http;
import 'package:sqflite/sqflite.dart';
import 'package:uuid/uuid.dart';
@ -43,9 +42,9 @@ Future<void> sendMessage(Conversation conversation, String data) async {
String sessionCookie = await getSessionCookie();
message.payloadJson(conversation, messageId)
.then((messageJson) {
.then((messageJson) async {
return http.post(
Uri.parse('${dotenv.env["SERVER_URL"]}api/v1/auth/message'),
await MyProfile.getServerUrl('api/v1/auth/message'),
headers: <String, String>{
'Content-Type': 'application/json; charset=UTF-8',
'cookie': sessionCookie,
@ -75,7 +74,7 @@ Future<void> updateMessageThread(Conversation conversation, {MyProfile? profile}
ConversationUser currentUser = await getConversationUser(conversation, profile.id);
var resp = await http.get(
Uri.parse('${dotenv.env["SERVER_URL"]}api/v1/auth/messages/${currentUser.associationKey}'),
await MyProfile.getServerUrl('api/v1/auth/messages/${currentUser.associationKey}'),
headers: {
'cookie': await getSessionCookie(),
}


+ 148
- 143
mobile/lib/views/authentication/login.dart View File

@ -1,7 +1,9 @@
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:http/http.dart' as http;
import 'package:flutter_dotenv/flutter_dotenv.dart';
import '/components/flash_message.dart';
import '/models/my_profile.dart';
import '/utils/storage/session_cookie.dart';
@ -34,51 +36,26 @@ class LoginResponse {
}
}
Future<dynamic> login(context, String username, String password) async {
final resp = await http.post(
Uri.parse('${dotenv.env["SERVER_URL"]}api/v1/login'),
headers: <String, String>{
'Content-Type': 'application/json; charset=UTF-8',
},
body: jsonEncode(<String, String>{
'username': username,
'password': password,
}),
);
if (resp.statusCode != 200) {
throw Exception(resp.body);
}
String? rawCookie = resp.headers['set-cookie'];
if (rawCookie != null) {
int index = rawCookie.indexOf(';');
setSessionCookie((index == -1) ? rawCookie : rawCookie.substring(0, index));
}
return await MyProfile.login(json.decode(resp.body), password);
}
class Login extends StatelessWidget {
const Login({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: null,
automaticallyImplyLeading: true,
leading: IconButton(icon: const Icon(Icons.arrow_back),
onPressed:() => {
Navigator.pop(context)
}
),
backgroundColor: Colors.transparent,
shadowColor: Colors.transparent,
),
body: const SafeArea(
child: LoginWidget(),
appBar: AppBar(
title: null,
automaticallyImplyLeading: true,
leading: IconButton(icon: const Icon(Icons.arrow_back),
onPressed:() => {
Navigator.pop(context)
}
),
backgroundColor: Colors.transparent,
shadowColor: Colors.transparent,
),
body: const SafeArea(
child: LoginWidget(),
),
);
}
}
@ -93,125 +70,153 @@ class LoginWidget extends StatefulWidget {
class _LoginWidgetState extends State<LoginWidget> {
final _formKey = GlobalKey<FormState>();
TextEditingController usernameController = TextEditingController();
TextEditingController passwordController = TextEditingController();
final TextEditingController _usernameController = TextEditingController();
final TextEditingController _passwordController = TextEditingController();
@override
Widget build(BuildContext context) {
const TextStyle inputTextStyle = TextStyle(
fontSize: 18,
fontSize: 18,
);
final OutlineInputBorder inputBorderStyle = OutlineInputBorder(
borderRadius: BorderRadius.circular(5),
borderSide: const BorderSide(
color: Colors.transparent,
)
borderRadius: BorderRadius.circular(5),
borderSide: const BorderSide(
color: Colors.transparent,
)
);
final ButtonStyle buttonStyle = ElevatedButton.styleFrom(
primary: Theme.of(context).colorScheme.surface,
onPrimary: Theme.of(context).colorScheme.onSurface,
minimumSize: const Size.fromHeight(50),
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 10),
textStyle: TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
color: Theme.of(context).colorScheme.error,
),
primary: Theme.of(context).colorScheme.surface,
onPrimary: Theme.of(context).colorScheme.onSurface,
minimumSize: const Size.fromHeight(50),
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 10),
textStyle: TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
color: Theme.of(context).colorScheme.error,
),
);
return Center(
child: Form(
key: _formKey,
child: Center(
child: Padding(
padding: const EdgeInsets.only(
left: 20,
right: 20,
top: 0,
bottom: 80,
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Text(
'Login',
style: TextStyle(
fontSize: 35,
color: Theme.of(context).colorScheme.onBackground,
),
),
const SizedBox(height: 30),
TextFormField(
controller: usernameController,
decoration: InputDecoration(
hintText: 'Username',
enabledBorder: inputBorderStyle,
focusedBorder: inputBorderStyle,
),
style: inputTextStyle,
// The validator receives the text that the user has entered.
validator: (value) {
if (value == null || value.isEmpty) {
return 'Enter a username';
}
return null;
},
),
const SizedBox(height: 10),
TextFormField(
controller: passwordController,
obscureText: true,
enableSuggestions: false,
autocorrect: false,
decoration: InputDecoration(
hintText: 'Password',
enabledBorder: inputBorderStyle,
focusedBorder: inputBorderStyle,
),
style: inputTextStyle,
// The validator receives the text that the user has entered.
validator: (value) {
if (value == null || value.isEmpty) {
return 'Enter a password';
}
return null;
},
),
const SizedBox(height: 15),
ElevatedButton(
style: buttonStyle,
onPressed: () {
if (_formKey.currentState!.validate()) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Processing Data')),
);
login(
context,
usernameController.text,
passwordController.text,
).then((val) {
Navigator.
pushNamedAndRemoveUntil(
context,
'/home',
ModalRoute.withName('/home'),
);
}).catchError((error) {
print(error); // TODO: Show error on interface
});
}
},
child: const Text('Submit'),
),
],
)
)
child: Form(
key: _formKey,
child: Center(
child: Padding(
padding: const EdgeInsets.only(
left: 20,
right: 20,
top: 0,
bottom: 80,
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Text(
'Login',
style: TextStyle(
fontSize: 35,
color: Theme.of(context).colorScheme.onBackground,
),
),
const SizedBox(height: 30),
TextFormField(
controller: _usernameController,
decoration: InputDecoration(
hintText: 'Username',
enabledBorder: inputBorderStyle,
focusedBorder: inputBorderStyle,
),
style: inputTextStyle,
// The validator receives the text that the user has entered.
validator: (value) {
if (value == null || value.isEmpty) {
return 'Enter a username';
}
return null;
},
),
const SizedBox(height: 10),
TextFormField(
controller: _passwordController,
obscureText: true,
enableSuggestions: false,
autocorrect: false,
decoration: InputDecoration(
hintText: 'Password',
enabledBorder: inputBorderStyle,
focusedBorder: inputBorderStyle,
),
style: inputTextStyle,
// The validator receives the text that the user has entered.
validator: (value) {
if (value == null || value.isEmpty) {
return 'Enter a password';
}
return null;
},
),
const SizedBox(height: 15),
ElevatedButton(
style: buttonStyle,
onPressed: () {
if (_formKey.currentState!.validate()) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Processing Data')),
);
login()
.then((val) {
Navigator.
pushNamedAndRemoveUntil(
context,
'/home',
ModalRoute.withName('/home'),
);
}).catchError((error) {
showMessage(
'Could not login to Envelope, please try again later.',
context,
);
});
}
},
child: const Text('Submit'),
),
],
)
)
)
)
);
}
Future<dynamic> login() async {
final resp = await http.post(
await MyProfile.getServerUrl('api/v1/login'),
headers: <String, String>{
'Content-Type': 'application/json; charset=UTF-8',
},
body: jsonEncode(<String, String>{
'username': _usernameController.text,
'password': _passwordController.text,
}),
);
if (resp.statusCode != 200) {
throw Exception(resp.body);
}
String? rawCookie = resp.headers['set-cookie'];
if (rawCookie != null) {
int index = rawCookie.indexOf(';');
setSessionCookie((index == -1) ? rawCookie : rawCookie.substring(0, index));
}
return await MyProfile.login(
json.decode(resp.body),
_passwordController.text,
);
}
}

+ 230
- 162
mobile/lib/views/authentication/signup.dart View File

@ -1,44 +1,14 @@
import 'dart:convert';
import 'dart:typed_data';
import 'package:Envelope/components/flash_message.dart';
import 'package:Envelope/models/my_profile.dart';
import 'package:flutter/material.dart';
import 'package:flutter_dotenv/flutter_dotenv.dart';
import 'package:http/http.dart' as http;
import '/utils/encryption/aes_helper.dart';
import '/utils/encryption/crypto_utils.dart';
Future<SignupResponse> signUp(context, String username, String password, String confirmPassword) async {
var keyPair = CryptoUtils.generateRSAKeyPair();
var rsaPubPem = CryptoUtils.encodeRSAPublicKeyToPem(keyPair.publicKey);
var rsaPrivPem = CryptoUtils.encodeRSAPrivateKeyToPem(keyPair.privateKey);
String encRsaPriv = AesHelper.aesEncrypt(password, Uint8List.fromList(rsaPrivPem.codeUnits));
// TODO: Check for timeout here
final resp = await http.post(
Uri.parse('${dotenv.env["SERVER_URL"]}api/v1/signup'),
headers: <String, String>{
'Content-Type': 'application/json; charset=UTF-8',
},
body: jsonEncode(<String, String>{
'username': username,
'password': password,
'confirm_password': confirmPassword,
'asymmetric_public_key': rsaPubPem,
'asymmetric_private_key': encRsaPriv,
}),
);
SignupResponse response = SignupResponse.fromJson(jsonDecode(resp.body));
if (resp.statusCode != 201) {
throw Exception(response.message);
}
return response;
}
class Signup extends StatelessWidget {
const Signup({Key? key}) : super(key: key);
@ -47,17 +17,17 @@ class Signup extends StatelessWidget {
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: null,
automaticallyImplyLeading: true,
//`true` if you want Flutter to automatically add Back Button when needed,
//or `false` if you want to force your own back button every where
leading: IconButton(icon: const Icon(Icons.arrow_back),
onPressed:() => {
Navigator.pop(context)
}
),
backgroundColor: Colors.transparent,
shadowColor: Colors.transparent,
title: null,
automaticallyImplyLeading: true,
//`true` if you want Flutter to automatically add Back Button when needed,
//or `false` if you want to force your own back button every where
leading: IconButton(icon: const Icon(Icons.arrow_back),
onPressed:() => {
Navigator.pop(context)
}
),
backgroundColor: Colors.transparent,
shadowColor: Colors.transparent,
),
body: const SafeArea(
child: SignupWidget(),
@ -77,8 +47,8 @@ class SignupResponse {
factory SignupResponse.fromJson(Map<String, dynamic> json) {
return SignupResponse(
status: json['status'],
message: json['message'],
status: json['status'],
message: json['message'],
);
}
}
@ -93,22 +63,26 @@ class SignupWidget extends StatefulWidget {
class _SignupWidgetState extends State<SignupWidget> {
final _formKey = GlobalKey<FormState>();
TextEditingController usernameController = TextEditingController();
TextEditingController passwordController = TextEditingController();
TextEditingController passwordConfirmController = TextEditingController();
final TextEditingController _usernameController = TextEditingController();
final TextEditingController _passwordController = TextEditingController();
final TextEditingController _passwordConfirmController = TextEditingController();
final TextEditingController _serverUrlController = TextEditingController();
bool showUrlInput = false;
final OutlineInputBorder inputBorderStyle = OutlineInputBorder(
borderRadius: BorderRadius.circular(5),
borderSide: const BorderSide(
color: Colors.transparent,
)
);
final TextStyle inputTextStyle = const TextStyle(
fontSize: 18,
);
@override
Widget build(BuildContext context) {
const TextStyle inputTextStyle = TextStyle(
fontSize: 18,
);
final OutlineInputBorder inputBorderStyle = OutlineInputBorder(
borderRadius: BorderRadius.circular(5),
borderSide: const BorderSide(
color: Colors.transparent,
)
);
final ButtonStyle buttonStyle = ElevatedButton.styleFrom(
primary: Theme.of(context).colorScheme.surface,
@ -123,115 +97,209 @@ class _SignupWidgetState extends State<SignupWidget> {
);
return Center(
child: SingleChildScrollView(
child: Form(
key: _formKey,
child: Center(
child: Padding(
padding: const EdgeInsets.only(
left: 20,
right: 20,
top: 0,
bottom: 100,
key: _formKey,
child: Center(
child: Padding(
padding: const EdgeInsets.only(
left: 20,
right: 20,
top: 0,
bottom: 100,
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Text(
'Sign Up',
style: TextStyle(
fontSize: 35,
color: Theme.of(context).colorScheme.onBackground,
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Text(
'Sign Up',
style: TextStyle(
fontSize: 35,
color: Theme.of(context).colorScheme.onBackground,
),
),
const SizedBox(height: 30),
TextFormField(
controller: usernameController,
decoration: InputDecoration(
hintText: 'Username',
enabledBorder: inputBorderStyle,
focusedBorder: inputBorderStyle,
),
style: inputTextStyle,
// The validator receives the text that the user has entered.
validator: (value) {
if (value == null || value.isEmpty) {
return 'Create a username';
}
return null;
},
),
const SizedBox(height: 10),
TextFormField(
controller: passwordController,
obscureText: true,
enableSuggestions: false,
autocorrect: false,
decoration: InputDecoration(
hintText: 'Password',
enabledBorder: inputBorderStyle,
focusedBorder: inputBorderStyle,
),
style: inputTextStyle,
// The validator receives the text that the user has entered.
validator: (value) {
if (value == null || value.isEmpty) {
return 'Enter a password';
}
return null;
},
),
const SizedBox(height: 10),
TextFormField(
controller: passwordConfirmController,
obscureText: true,
enableSuggestions: false,
autocorrect: false,
decoration: InputDecoration(
hintText: 'Password',
enabledBorder: inputBorderStyle,
focusedBorder: inputBorderStyle,
),
style: inputTextStyle,
// The validator receives the text that the user has entered.
validator: (value) {
if (value == null || value.isEmpty) {
return 'Confirm your password';
}
if (value != passwordController.text) {
return 'Passwords do not match';
}
return null;
},
),
const SizedBox(height: 15),
ElevatedButton(
style: buttonStyle,
onPressed: () {
if (_formKey.currentState!.validate()) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Processing Data')),
);
signUp(
context,
usernameController.text,
passwordController.text,
passwordConfirmController.text
).then((value) {
Navigator.of(context).popUntil((route) => route.isFirst);
}).catchError((error) {
print(error); // TODO: Show error on interface
});
}
},
child: const Text('Submit'),
),
],
)
),
const SizedBox(height: 30),
input(
_usernameController,
'Username',
false,
(value) {
if (value == null || value.isEmpty) {
return 'Create a username';
}
return null;
},
),
const SizedBox(height: 10),
input(
_passwordController,
'Password',
true,
(value) {
if (value == null || value.isEmpty) {
return 'Enter a password';
}
return null;
},
),
const SizedBox(height: 10),
input(
_passwordConfirmController,
'Confirm Password',
true,
(value) {
if (value == null || value.isEmpty) {
return 'Confirm your password';
}
if (value != _passwordController.text) {
return 'Passwords do not match';
}
return null;
},
),
const SizedBox(height: 15),
serverUrl(),
const SizedBox(height: 15),
ElevatedButton(
style: buttonStyle,
onPressed: () {
if (!_formKey.currentState!.validate()) {
return;
}
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Processing Data')),
);
signUp()
.then((dynamic) {
Navigator.of(context).popUntil((route) => route.isFirst);
}).catchError((error) {
showMessage('Failed to signup to Envelope, please try again later', context);
});
},
child: const Text('Submit'),
),
],
)
)
)
)
)
);
}
Widget input(
TextEditingController textController,
String hintText,
bool password,
String? Function(dynamic) validationFunction,
) {
return TextFormField(
controller: textController,
obscureText: password,
enableSuggestions: false,
autocorrect: false,
decoration: InputDecoration(
hintText: hintText,
enabledBorder: inputBorderStyle,
focusedBorder: inputBorderStyle,
),
style: inputTextStyle,
validator: validationFunction,
);
}
Widget serverUrl() {
if (!showUrlInput) {
return
Padding(
padding: const EdgeInsets.only(top: 0, bottom: 10),
child: Row(
children: [
SizedBox(
height: 10,
child: IconButton(
onPressed: () {
setState(() {
showUrlInput = true;
});
},
icon: Icon(
Icons.edit,
color: Theme.of(context).disabledColor,
),
splashRadius: 2,
padding: const EdgeInsets.all(2),
iconSize: 15,
),
),
const SizedBox(width: 2),
Column(
children: [
const SizedBox(height: 10),
Text(
'Server URL - $defaultServerUrl',
style: TextStyle(
color: Theme.of(context).disabledColor,
fontSize: 12,
),
),
],
),
],
),
);
}
if (_serverUrlController.text == '') {
_serverUrlController.text = defaultServerUrl;
}
return input(
_serverUrlController,
'Server URL',
false,
(dynamic) {
return null;
},
);
}
Future<SignupResponse> signUp() async {
await MyProfile.setServerUrl(_serverUrlController.text);
var keyPair = CryptoUtils.generateRSAKeyPair();
var rsaPubPem = CryptoUtils.encodeRSAPublicKeyToPem(keyPair.publicKey);
var rsaPrivPem = CryptoUtils.encodeRSAPrivateKeyToPem(keyPair.privateKey);
String encRsaPriv = AesHelper.aesEncrypt(
_passwordController.text,
Uint8List.fromList(rsaPrivPem.codeUnits),
);
final resp = await http.post(
await MyProfile.getServerUrl('api/v1/signup'),
headers: <String, String>{
'Content-Type': 'application/json; charset=UTF-8',
},
body: jsonEncode(<String, String>{
'username': _usernameController.text,
'password': _passwordController.text,
'confirm_password': _passwordConfirmController.text,
'asymmetric_public_key': rsaPubPem,
'asymmetric_private_key': encRsaPriv,
}),
);
SignupResponse response = SignupResponse.fromJson(jsonDecode(resp.body));
if (resp.statusCode != 201) {
throw Exception(response.message);
}
return response;
}
}

+ 5
- 1
mobile/lib/views/main/conversation/detail.dart View File

@ -22,7 +22,11 @@ class ConversationDetail extends StatefulWidget{
class _ConversationDetailState extends State<ConversationDetail> {
List<Message> messages = [];
MyProfile profile = MyProfile(id: '', username: '');
MyProfile profile = MyProfile(
id: '',
username: '',
messageExpiryDefault: 'no_expiry',
);
TextEditingController msgController = TextEditingController();


+ 1
- 1
mobile/lib/views/main/conversation/list.dart View File

@ -47,7 +47,7 @@ class _ConversationListState extends State<ConversationList> {
children: <Widget>[
TextField(
decoration: const InputDecoration(
hintText: "Search...",
hintText: 'Search...',
prefixIcon: Icon(
Icons.search,
size: 20


+ 57
- 56
mobile/lib/views/main/conversation/list_item.dart View File

@ -6,7 +6,7 @@ import '/models/conversations.dart';
import '/views/main/conversation/detail.dart';
import '/utils/time.dart';
class ConversationListItem extends StatefulWidget{
class ConversationListItem extends StatefulWidget {
final Conversation conversation;
const ConversationListItem({
Key? key,
@ -33,70 +33,71 @@ class _ConversationListItemState extends State<ConversationListItem> {
);
})).then(onGoBack) : null;
},
child: Container(
padding: const EdgeInsets.only(left: 16,right: 0,top: 10,bottom: 10),
child: !loaded ? null : Row(
padding: const EdgeInsets.only(left: 16,right: 0,top: 10,bottom: 10),
child: !loaded ? null : Row(
children: <Widget>[
Expanded(
child: Row(
children: <Widget>[
CustomCircleAvatar(
initials: widget.conversation.name[0].toUpperCase(),
imagePath: null,
),
const SizedBox(width: 16),
Expanded(
child: Row(
children: <Widget>[
CustomCircleAvatar(
initials: widget.conversation.name[0].toUpperCase(),
imagePath: null,
),
const SizedBox(width: 16),
Expanded(
child: Align(
alignment: Alignment.centerLeft,
child: Container(
color: Colors.transparent,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text(
widget.conversation.name,
style: const TextStyle(fontSize: 16)
),
recentMessage != null ?
const SizedBox(height: 2) :
const SizedBox.shrink()
,
recentMessage != null ?
Text(
recentMessage!.data,
overflow: TextOverflow.ellipsis,
maxLines: 1,
style: TextStyle(
fontSize: 13,
color: Colors.grey.shade600,
fontWeight: conversation.isRead ? FontWeight.normal : FontWeight.bold,
),
) :
const SizedBox.shrink(),
],
),
),
),
child: Align(
alignment: Alignment.centerLeft,
child: Container(
color: Colors.transparent,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text(
widget.conversation.name,
style: const TextStyle(fontSize: 16)
),
recentMessage != null ?
const SizedBox(height: 2) :
const SizedBox.shrink()
,
recentMessage != null ?
Text(
recentMessage!.data,
overflow: TextOverflow.ellipsis,
maxLines: 1,
style: TextStyle(
fontSize: 13,
color: Colors.grey.shade600,
fontWeight: conversation.isRead ? FontWeight.normal : FontWeight.bold,
),
) :
const SizedBox.shrink(),
],
),
recentMessage != null ?
Padding(
padding: const EdgeInsets.only(left: 10),
child: Text(
convertToAgo(recentMessage!.createdAt, short: true),
style: TextStyle(
fontSize: 13,
color: Colors.grey.shade600,
),
)
):
const SizedBox.shrink(),
],
),
),
),
recentMessage != null ?
Padding(
padding: const EdgeInsets.only(left: 10),
child: Text(
convertToAgo(recentMessage!.createdAt, short: true),
style: TextStyle(
fontSize: 13,
color: Colors.grey.shade600,
),
)
):
const SizedBox.shrink(),
],
),
),
);
],
),
),
);
}
@override


+ 2
- 3
mobile/lib/views/main/friend/add_search.dart View File

@ -2,12 +2,11 @@ import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:http/http.dart' as http;
import 'package:flutter_dotenv/flutter_dotenv.dart';
import '/utils/storage/session_cookie.dart';
import '/components/user_search_result.dart';
import '/data_models/user_search.dart';
import '/models/my_profile.dart';
class FriendAddSearch extends StatefulWidget {
const FriendAddSearch({
@ -123,7 +122,7 @@ class _FriendAddSearchState extends State<FriendAddSearch> {
Map<String, String> params = {};
params['username'] = searchController.text;
var uri = Uri.parse('${dotenv.env["SERVER_URL"]}api/v1/auth/users');
var uri = await MyProfile.getServerUrl('api/v1/auth/users');
uri = uri.replace(queryParameters: params);
var resp = await http.get(


+ 5
- 6
mobile/lib/views/main/friend/request_list_item.dart View File

@ -1,16 +1,15 @@
import 'dart:convert';
import 'dart:typed_data';
import 'package:Envelope/components/flash_message.dart';
import 'package:Envelope/utils/storage/database.dart';
import 'package:flutter/material.dart';
import 'package:flutter_dotenv/flutter_dotenv.dart';
import 'package:http/http.dart' as http;
import '/components/flash_message.dart';
import '/components/custom_circle_avatar.dart';
import '/models/friends.dart';
import '/utils/storage/session_cookie.dart';
import '/models/my_profile.dart';
import '/utils/storage/session_cookie.dart';
import '/utils/storage/database.dart';
import '/utils/encryption/aes_helper.dart';
import '/utils/encryption/crypto_utils.dart';
import '/utils/strings.dart';
@ -122,7 +121,7 @@ class _FriendRequestListItemState extends State<FriendRequestListItem> {
});
var resp = await http.post(
Uri.parse('${dotenv.env["SERVER_URL"]}api/v1/auth/friend_request/${widget.friend.id}'),
await MyProfile.getServerUrl('api/v1/auth/friend_request/${widget.friend.id}'),
headers: {
'cookie': await getSessionCookie(),
},
@ -153,7 +152,7 @@ class _FriendRequestListItemState extends State<FriendRequestListItem> {
Future<void> rejectFriendRequest() async {
var resp = await http.delete(
Uri.parse('${dotenv.env["SERVER_URL"]}api/v1/auth/friend_request/${widget.friend.id}'),
await MyProfile.getServerUrl('api/v1/auth/friend_request/${widget.friend.id}'),
headers: {
'cookie': await getSessionCookie(),
},


+ 9
- 8
mobile/lib/views/main/home.dart View File

@ -1,5 +1,4 @@
import 'package:flutter/material.dart';
import 'package:flutter_dotenv/flutter_dotenv.dart';
import 'package:http/http.dart' as http;
import '/models/conversations.dart';
@ -25,8 +24,9 @@ class _HomeState extends State<Home> {
List<Friend> friends = [];
List<Friend> friendRequests = [];
MyProfile profile = MyProfile(
id: '',
username: '',
id: '',
username: '',
messageExpiryDefault: 'no_expiry',
);
bool isLoading = true;
@ -35,10 +35,11 @@ class _HomeState extends State<Home> {
const ConversationList(conversations: [], friends: []),
FriendList(friends: const [], friendRequests: const [], callback: () {}),
Profile(
profile: MyProfile(
id: '',
username: '',
)
profile: MyProfile(
id: '',
username: '',
messageExpiryDefault: 'no_expiry',
)
),
];
@ -94,7 +95,7 @@ class _HomeState extends State<Home> {
int statusCode = 200;
try {
var resp = await http.get(
Uri.parse('${dotenv.env["SERVER_URL"]}api/v1/auth/check'),
await MyProfile.getServerUrl('api/v1/auth/check'),
headers: {
'cookie': await getSessionCookie(),
}


+ 180
- 0
mobile/lib/views/main/profile/change_password.dart View File

@ -0,0 +1,180 @@
import 'dart:convert';
import 'dart:typed_data';
import 'package:http/http.dart' as http;
import 'package:flutter/material.dart';
import 'package:pointycastle/impl.dart';
import '/components/flash_message.dart';
import '/components/custom_title_bar.dart';
import '/models/my_profile.dart';
import '/utils/encryption/aes_helper.dart';
import '/utils/encryption/crypto_utils.dart';
import '/utils/storage/session_cookie.dart';
@immutable
class ChangePassword extends StatelessWidget {
ChangePassword({
Key? key,
required this.privateKey
}) : super(key: key);
final RSAPrivateKey privateKey;
final _formKey = GlobalKey<FormState>();
final TextEditingController _currentPasswordController = TextEditingController();
final TextEditingController _newPasswordController = TextEditingController();
final TextEditingController _newPasswordConfirmController = TextEditingController();
bool invalidCurrentPassword = false;
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: const CustomTitleBar(
title: Text(
'Profile',
style: TextStyle(
fontSize: 32,
fontWeight: FontWeight.bold
)
),
showBack: true,
backgroundColor: Colors.transparent,
),
body: Form(
key: _formKey,
child: Padding(
padding: const EdgeInsets.only(
left: 20,
right: 20,
top: 30,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
const Text(
'Change Password',
style: TextStyle(
fontSize: 25,
),
),
const SizedBox(height: 30),
TextFormField(
controller: _currentPasswordController,
decoration: const InputDecoration(
hintText: 'Current Password',
),
// The validator receives the text that the user has entered.
validator: (value) {
if (value == null || value.isEmpty) {
return 'Enter your current password';
}
if (invalidCurrentPassword) {
return 'Invalid password';
}
return null;
},
),
const SizedBox(height: 10),
TextFormField(
controller: _newPasswordController,
obscureText: true,
enableSuggestions: false,
autocorrect: false,
decoration: const InputDecoration(
hintText: 'New Password',
),
// The validator receives the text that the user has entered.
validator: (value) {
if (value == null || value.isEmpty) {
return 'Enter a new password';
}
return null;
},
),
const SizedBox(height: 10),
TextFormField(
controller: _newPasswordConfirmController,
obscureText: true,
enableSuggestions: false,
autocorrect: false,
decoration: const InputDecoration(
hintText: 'Confirm Password',
),
// The validator receives the text that the user has entered.
validator: (value) {
if (value == null || value.isEmpty) {
return 'Confirm your password';
}
if (value != _newPasswordController.text) {
return 'Passwords do not match';
}
return null;
},
),
const SizedBox(height: 15),
ElevatedButton(
onPressed: () {
if (!_formKey.currentState!.validate()) {
return;
}
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Processing Data')),
);
_changePassword(context)
.then((dynamic) {
Navigator.of(context).pop();
});
},
child: const Text('Submit'),
),
],
)
)
)
);
}
Future<void> _changePassword(BuildContext context) async {
String privateKeyPem = CryptoUtils.encodeRSAPrivateKeyToPem(privateKey);
String privateKeyEncrypted = AesHelper.aesEncrypt(
_newPasswordController.text,
Uint8List.fromList(privateKeyPem.codeUnits),
);
String payload = jsonEncode({
'old_password': _currentPasswordController.text,
'new_password': _newPasswordController.text,
'new_password_confirm': _newPasswordConfirmController.text,
'private_key': privateKeyEncrypted,
});
var resp = await http.post(
await MyProfile.getServerUrl('api/v1/auth/change_password'),
headers: <String, String>{
'Content-Type': 'application/json; charset=UTF-8',
'cookie': await getSessionCookie(),
},
body: payload,
);
if (resp.statusCode == 403) {
invalidCurrentPassword = true;
_formKey.currentState!.validate();
return;
}
if (resp.statusCode != 200) {
showMessage(
'An unexpected error occured, please try again later.',
context,
);
}
}
}

+ 142
- 0
mobile/lib/views/main/profile/change_server_url.dart View File

@ -0,0 +1,142 @@
import 'package:flutter/material.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '/components/custom_title_bar.dart';
import '/models/my_profile.dart';
import '/utils/storage/database.dart';
@immutable
class ChangeServerUrl extends StatefulWidget {
const ChangeServerUrl({
Key? key,
}) : super(key: key);
@override
State<ChangeServerUrl> createState() => _ChangeServerUrl();
}
class _ChangeServerUrl extends State<ChangeServerUrl> {
final _formKey = GlobalKey<FormState>();
final TextEditingController _serverUrlController = TextEditingController();
bool invalidCurrentPassword = false;
@override
void initState() {
setUrl();
super.initState();
}
Future<void> setUrl() async {
final preferences = await SharedPreferences.getInstance();
_serverUrlController.text = preferences.getString('server_url') ?? defaultServerUrl;
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: const CustomTitleBar(
title: Text(
'Profile',
style: TextStyle(
fontSize: 32,
fontWeight: FontWeight.bold
)
),
showBack: true,
backgroundColor: Colors.transparent,
),
body: SingleChildScrollView(
child: Center(
child: Form(
key: _formKey,
child: Padding(
padding: const EdgeInsets.only(
left: 20,
right: 20,
top: 30,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
const Text(
'Change Server Url',
style: TextStyle(
fontSize: 25,
),
),
const SizedBox(height: 30),
showWarning(),
const SizedBox(height: 30),
TextFormField(
controller: _serverUrlController,
decoration: const InputDecoration(
hintText: 'Server Url',
),
// The validator receives the text that the user has entered.
validator: (String? value) {
if (value == null || !Uri.parse(value).isAbsolute) {
return 'Invalid URL';
}
return null;
},
),
const SizedBox(height: 15),
ElevatedButton(
onPressed: () {
if (!_formKey.currentState!.validate()) {
return;
}
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Processing Data')),
);
},
child: const Text('CHANGE SERVER URL'),
),
],
)
)
)
)
)
);
}
Widget showWarning() {
String warning1 = '''
WARNING: Do not use this feature unless you know what you\'re doing!
''';
String warning2 = '''
Changing the server url will disconnect you from all friends and conversations on this server, and connect you to a fresh environment. This feature is intended to be used by people that are willing to host their own Envelope server, which you can find by going to \nhttps://github.com/SomeUsername/SomeRepo.\n\n
You can revert this by entering \nhttps://envelope-messenger.com\n on the login screen.
''';
return Column(
children: [
Text(
warning1,
textAlign: TextAlign.center,
style: TextStyle(
color: Theme.of(context).colorScheme.error,
)
),
Text(
warning2,
textAlign: TextAlign.center,
),
],
);
}
// TODO: Write user data to new server??
Future<void> changeUrl() async {
MyProfile.setServerUrl(_serverUrlController.text);
deleteDb();
MyProfile.logout();
Navigator.pushNamedAndRemoveUntil(context, '/landing', ModalRoute.withName('/landing'));
}
}

+ 215
- 94
mobile/lib/views/main/profile/profile.dart View File

@ -1,10 +1,21 @@
import 'package:Envelope/components/custom_title_bar.dart';
import 'dart:convert';
import 'package:Envelope/components/flash_message.dart';
import 'package:Envelope/utils/storage/session_cookie.dart';
import 'package:flutter/material.dart';
import 'package:flutter_dotenv/flutter_dotenv.dart';
import 'package:qr_flutter/qr_flutter.dart';
import '/utils/storage/database.dart';
import '/models/my_profile.dart';
import 'package:sliding_up_panel/sliding_up_panel.dart';
import 'package:http/http.dart' as http;
import '/components/select_message_ttl.dart';
import '/components/custom_circle_avatar.dart';
import '/components/custom_title_bar.dart';
import '/models/my_profile.dart';
import '/utils/encryption/crypto_utils.dart';
import '/utils/storage/database.dart';
import '/views/main/profile/change_password.dart';
import '/views/main/profile/change_server_url.dart';
class Profile extends StatefulWidget {
final MyProfile profile;
@ -18,81 +29,81 @@ class Profile extends StatefulWidget {
}
class _ProfileState extends State<Profile> {
Widget usernameHeading() {
return Row(
children: <Widget> [
const CustomCircleAvatar(
icon: Icon(Icons.person, size: 40),
imagePath: null, // TODO: Add image here
radius: 30,
),
const SizedBox(width: 20),
Text(
widget.profile.username,
style: const TextStyle(
fontSize: 25,
fontWeight: FontWeight.w500,
),
),
// widget.conversation.admin ? IconButton(
// iconSize: 20,
// icon: const Icon(Icons.edit),
// padding: const EdgeInsets.all(5.0),
// splashRadius: 25,
// onPressed: () {
// // TODO: Redirect to edit screen
// },
// ) : const SizedBox.shrink(),
],
);
}
final PanelController _panelController = PanelController();
Widget _profileQrCode() {
return Container(
child: QrImage(
data: 'This is a simple QR code',
version: QrVersions.auto,
size: 130,
gapless: true,
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: const CustomTitleBar(
title: Text(
'Profile',
style: TextStyle(
fontSize: 32,
fontWeight: FontWeight.bold
)
),
width: 130,
height: 130,
color: Theme.of(context).colorScheme.onPrimary,
showBack: false,
backgroundColor: Colors.transparent,
),
body: SlidingUpPanel(
controller: _panelController,
slideDirection: SlideDirection.DOWN,
defaultPanelState: PanelState.CLOSED,
color: Theme.of(context).scaffoldBackgroundColor,
backdropTapClosesPanel: true,
backdropEnabled: true,
backdropOpacity: 0.2,
minHeight: 0,
maxHeight: 450,
panel: Center(
child: _profileQrCode(),
),
body: Padding(
padding: const EdgeInsets.only(top: 16,left: 16,right: 16),
child: Column(
children: <Widget>[
usernameHeading(),
const SizedBox(height: 30),
settings(),
const SizedBox(height: 30),
logout(),
],
)
),
),
);
}
Widget settings() {
return Align(
alignment: Alignment.centerLeft,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const SizedBox(height: 5),
TextButton.icon(
label: const Text(
'Disappearing Messages',
style: TextStyle(fontSize: 16)
),
icon: const Icon(Icons.timer),
style: ButtonStyle(
alignment: Alignment.centerLeft,
foregroundColor: MaterialStateProperty.resolveWith<Color>(
(Set<MaterialState> states) {
return Theme.of(context).colorScheme.onBackground;
},
)
),
onPressed: () {
print('Disappearing Messages');
}
Widget usernameHeading() {
return Row(
children: <Widget> [
const CustomCircleAvatar(
icon: Icon(Icons.person, size: 40),
imagePath: null, // TODO: Add image here
radius: 30,
),
const SizedBox(width: 20),
Expanded(
flex: 1,
child: Text(
widget.profile.username,
style: const TextStyle(
fontSize: 25,
fontWeight: FontWeight.w500,
),
],
),
),
),
IconButton(
onPressed: () => _panelController.open(),
icon: const Icon(Icons.qr_code_2),
),
],
);
}
Widget logout() {
bool isTesting = dotenv.env["ENVIRONMENT"] == 'development';
bool isTesting = dotenv.env['ENVIRONMENT'] == 'development';
return Align(
alignment: Alignment.centerLeft,
child: Column(
@ -131,34 +142,144 @@ class _ProfileState extends State<Profile> {
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: const CustomTitleBar(
title: Text(
'Profile',
style: TextStyle(
fontSize: 32,
fontWeight: FontWeight.bold
)
),
showBack: false,
backgroundColor: Colors.transparent,
Widget settings() {
return Align(
alignment: Alignment.centerLeft,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const SizedBox(height: 5),
TextButton.icon(
label: const Text(
'Disappearing Messages',
style: TextStyle(fontSize: 16)
),
icon: const Icon(Icons.timer),
style: ButtonStyle(
alignment: Alignment.centerLeft,
foregroundColor: MaterialStateProperty.resolveWith<Color>(
(Set<MaterialState> states) {
return Theme.of(context).colorScheme.onBackground;
},
)
),
onPressed: () {
Navigator.of(context).push(
MaterialPageRoute(builder: (context) => SelectMessageTTL(
widgetTitle: 'Message Expiry',
currentSelected: widget.profile.messageExpiryDefault,
backCallback: (String messageExpiry) async {
widget.profile.messageExpiryDefault = messageExpiry;
http.post(
await MyProfile.getServerUrl('api/v1/auth/message_expiry'),
headers: {
'cookie': await getSessionCookie(),
},
body: jsonEncode({
'message_expiry': messageExpiry,
}),
).then((http.Response response) {
if (response.statusCode == 200) {
return;
}
showMessage(
'Could not change your default message expiry, please try again later.',
context,
);
});
},
))
);
}
),
const SizedBox(height: 5),
TextButton.icon(
label: const Text(
'Server URL',
style: TextStyle(fontSize: 16)
),
icon: const Icon(Icons.dataset_linked_outlined),
style: ButtonStyle(
alignment: Alignment.centerLeft,
foregroundColor: MaterialStateProperty.resolveWith<Color>(
(Set<MaterialState> states) {
return Theme.of(context).colorScheme.onBackground;
},
)
),
onPressed: () {
Navigator.of(context).push(
MaterialPageRoute(builder: (context) => const ChangeServerUrl())
);
}
),
const SizedBox(height: 5),
TextButton.icon(
label: const Text(
'Change Password',
style: TextStyle(fontSize: 16)
),
icon: const Icon(Icons.password),
style: ButtonStyle(
alignment: Alignment.centerLeft,
foregroundColor: MaterialStateProperty.resolveWith<Color>(
(Set<MaterialState> states) {
return Theme.of(context).colorScheme.onBackground;
},
)
),
onPressed: () {
Navigator.of(context).push(
MaterialPageRoute(builder: (context) => ChangePassword(
privateKey: widget.profile.privateKey!,
))
);
}
),
],
),
body: Padding(
padding: const EdgeInsets.only(top: 16,left: 16,right: 16),
child: Column(
children: <Widget>[
usernameHeading(),
const SizedBox(height: 30),
_profileQrCode(),
const SizedBox(height: 30),
settings(),
const SizedBox(height: 30),
logout(),
],
)
);
}
Widget _profileQrCode() {
String payload = jsonEncode({
'i': widget.profile.id,
'u': widget.profile.username,
'k': base64.encode(
CryptoUtils.encodeRSAPublicKeyToPem(widget.profile.publicKey!).codeUnits
),
});
return Column(
children: [
Padding(
padding: const EdgeInsets.all(20),
child: QrImage(
backgroundColor: Theme.of(context).colorScheme.primary,
data: payload,
version: QrVersions.auto,
gapless: true,
),
),
Align(
alignment: Alignment.centerRight,
child: Padding(
padding: const EdgeInsets.only(right: 20),
child: IconButton(
onPressed: () => _panelController.close(),
icon: const Icon(Icons.arrow_upward),
),
),
),
]
);
}
}

+ 7
- 0
mobile/pubspec.lock View File

@ -322,6 +322,13 @@ packages:
description: flutter
source: sdk
version: "0.0.99"
sliding_up_panel:
dependency: "direct main"
description:
name: sliding_up_panel
url: "https://pub.dartlang.org"
source: hosted
version: "2.0.0+1"
source_span:
dependency: transitive
description:


+ 1
- 0
mobile/pubspec.yaml View File

@ -24,6 +24,7 @@ dependencies:
uuid: ^3.0.6
qr_flutter: ^4.0.0
qr_code_scanner: ^1.0.1
sliding_up_panel: ^2.0.0+1
dev_dependencies:
flutter_test:


Loading…
Cancel
Save