ids, long now);
+
+ /** Count total cached mails (for LRU capping). */
+ @Query("SELECT COUNT(*) FROM mails")
+ int countAll();
+
+ /** Kill oldest rows by lastAccessEpoch (simple LRU). */
+ @Query("DELETE FROM mails WHERE id IN (" +
+ "SELECT id FROM mails ORDER BY lastAccessEpoch ASC LIMIT :howMany)")
+ void evictOldest(int howMany);
+
+ /** Remove everything (used on logout). */
+ @Query("DELETE FROM mails")
+ void clearAll();
+}
\ No newline at end of file
diff --git a/android_app/app/src/main/java/com/asp/android_app/caching/entities/LabelEntity.java b/android_app/app/src/main/java/com/asp/android_app/caching/entities/LabelEntity.java
new file mode 100644
index 00000000..1a1063b9
--- /dev/null
+++ b/android_app/app/src/main/java/com/asp/android_app/caching/entities/LabelEntity.java
@@ -0,0 +1,17 @@
+package com.asp.android_app.caching.entities;
+
+import androidx.room.Entity;
+import androidx.room.PrimaryKey;
+
+/**
+ * Room cache for a Label as returned by backend.
+ * Parent is an Integer (nullable) in the server model,
+ * stored here as Integer-compatible (use -1 for "no parent" if you prefer).
+ */
+@Entity(tableName = "labels")
+public class LabelEntity {
+ @PrimaryKey
+ public int id;
+ public String name;
+ public Integer parent; // null = no parent
+}
diff --git a/android_app/app/src/main/java/com/asp/android_app/caching/entities/MailEntity.java b/android_app/app/src/main/java/com/asp/android_app/caching/entities/MailEntity.java
new file mode 100644
index 00000000..cdfae2df
--- /dev/null
+++ b/android_app/app/src/main/java/com/asp/android_app/caching/entities/MailEntity.java
@@ -0,0 +1,64 @@
+// Updated MailEntity.java
+package com.asp.android_app.caching.entities;
+
+import androidx.room.Entity;
+import androidx.room.Index;
+import androidx.room.PrimaryKey;
+
+/**
+ * Room cache model for a Mail.
+ * - Denormalized for speed (flags directly on the row).
+ * - We keep a few denormalized sender fields so lists render without joins.
+ * - Recipients & attachment data are stored as JSON strings (simple & fast).
+ *
+ * NOTE: We sort by sentAtEpoch (fallback to createdAtEpoch) to match backend's order.
+ */
+@Entity(tableName = "mails",
+ indices = {
+ @Index(value = {"id"}, unique = true),
+ @Index(value = {"ownerId"}),
+ @Index(value = {"isDraft"}),
+ @Index(value = {"isRead"}),
+ @Index(value = {"isStarred"}),
+ @Index(value = {"isTrashed"}),
+ @Index(value = {"isSpam"}),
+ @Index(value = {"sentAtEpoch"}),
+ @Index(value = {"createdAtEpoch"})
+ })
+public class MailEntity {
+
+ @PrimaryKey
+ public int id; // server mail id
+
+ public int ownerId; // owner per server model
+
+ // Sender (denormalized for quick list rendering)
+ public int fromId;
+ public String fromName;
+ public String fromEmail;
+ public String fromImageUrl;
+
+ // Main content
+ public String subject;
+ public String body;
+
+ // Dates: raw ISO + parsed epoch for sort/filter
+ public String sentAtRaw; // can be "" for drafts
+ public String createdAtRaw;
+ public long sentAtEpoch; // 0 if unknown
+ public long createdAtEpoch; // fallback
+
+ // Flags (we derive inboxes by these)
+ public boolean isDraft;
+ public boolean isRead;
+ public boolean isStarred;
+ public boolean isTrashed;
+ public boolean isSpam;
+
+ // JSON blobs to keep schema simple
+ public String recipientsJson; // List as JSON
+ public String attachmentsJson; // CHANGED: Full File objects as JSON instead of just names
+
+ // Bookkeeping for cache eviction (simple LRU)
+ public long lastAccessEpoch; // updated whenever we read/open
+}
diff --git a/android_app/app/src/main/java/com/asp/android_app/caching/entities/MailLabelCrossRef.java b/android_app/app/src/main/java/com/asp/android_app/caching/entities/MailLabelCrossRef.java
new file mode 100644
index 00000000..0df43d59
--- /dev/null
+++ b/android_app/app/src/main/java/com/asp/android_app/caching/entities/MailLabelCrossRef.java
@@ -0,0 +1,33 @@
+package com.asp.android_app.caching.entities;
+
+import androidx.room.Entity;
+import androidx.room.ForeignKey;
+import androidx.room.Index;
+
+/**
+ * Many-to-many relation between mails and labels.
+ * We only need mailId & labelId since both sides are unique.
+ */
+@Entity(tableName = "mail_label",
+ primaryKeys = {"mailId", "labelId"},
+ foreignKeys = {
+ @ForeignKey(entity = MailEntity.class,
+ parentColumns = "id", childColumns = "mailId",
+ onDelete = ForeignKey.CASCADE),
+ @ForeignKey(entity = LabelEntity.class,
+ parentColumns = "id", childColumns = "labelId",
+ onDelete = ForeignKey.CASCADE)
+ },
+ indices = {
+ @Index("mailId"),
+ @Index("labelId")
+ })
+public class MailLabelCrossRef {
+ public int mailId;
+ public int labelId;
+
+ public MailLabelCrossRef(int mailId, int labelId) {
+ this.mailId = mailId;
+ this.labelId = labelId;
+ }
+}
diff --git a/android_app/app/src/main/java/com/asp/android_app/caching/entities/UserLite.java b/android_app/app/src/main/java/com/asp/android_app/caching/entities/UserLite.java
new file mode 100644
index 00000000..40b2bc0c
--- /dev/null
+++ b/android_app/app/src/main/java/com/asp/android_app/caching/entities/UserLite.java
@@ -0,0 +1,19 @@
+package com.asp.android_app.caching.entities;
+
+/**
+ * Lightweight user used inside Room JSON columns (recipients).
+ * This mirrors the subset we need for offline display.
+ */
+public class UserLite {
+ public int id;
+ public String mail;
+ public String fullName;
+ public String imageUrl; // optional
+
+ public UserLite(int id, String mail, String fullName, String imageUrl) {
+ this.id = id;
+ this.mail = mail;
+ this.fullName = fullName;
+ this.imageUrl = imageUrl;
+ }
+}
diff --git a/android_app/app/src/main/java/com/asp/android_app/caching/utils/JsonConverters.java b/android_app/app/src/main/java/com/asp/android_app/caching/utils/JsonConverters.java
new file mode 100644
index 00000000..4da0dfff
--- /dev/null
+++ b/android_app/app/src/main/java/com/asp/android_app/caching/utils/JsonConverters.java
@@ -0,0 +1,62 @@
+// Updated JsonConverters.java
+package com.asp.android_app.caching.utils;
+
+import androidx.annotation.Nullable;
+import androidx.room.TypeConverter;
+
+import com.asp.android_app.caching.entities.UserLite;
+import com.asp.android_app.model.response.File;
+import com.google.gson.Gson;
+import com.google.gson.reflect.TypeToken;
+
+import java.lang.reflect.Type;
+import java.util.Collections;
+import java.util.List;
+
+/**
+ * Room TypeConverters for small JSON blobs.
+ * - List for simple string lists
+ * - List for recipients
+ * - List for complete attachment information
+ * Keeping JSON simple avoids extra tables until we actually need them.
+ */
+public class JsonConverters {
+ private static final Gson gson = new Gson();
+ private static final Type LIST_STRING = new TypeToken>() {}.getType();
+ private static final Type LIST_USERLITE = new TypeToken>() {}.getType();
+ private static final Type LIST_FILE = new TypeToken>() {}.getType(); // ADDED
+
+ @TypeConverter
+ public static String listStringToJson(@Nullable List list) {
+ return gson.toJson(list == null ? Collections.emptyList() : list, LIST_STRING);
+ }
+
+ @TypeConverter
+ public static List jsonToListString(@Nullable String json) {
+ if (json == null || json.isEmpty()) return Collections.emptyList();
+ return gson.fromJson(json, LIST_STRING);
+ }
+
+ @TypeConverter
+ public static String listUserLiteToJson(@Nullable List list) {
+ return gson.toJson(list == null ? Collections.emptyList() : list, LIST_USERLITE);
+ }
+
+ @TypeConverter
+ public static List jsonToListUserLite(@Nullable String json) {
+ if (json == null || json.isEmpty()) return Collections.emptyList();
+ return gson.fromJson(json, LIST_USERLITE);
+ }
+
+ // ADDED: Converters for File objects
+ @TypeConverter
+ public static String listFileToJson(@Nullable List list) {
+ return gson.toJson(list == null ? Collections.emptyList() : list, LIST_FILE);
+ }
+
+ @TypeConverter
+ public static List jsonToListFile(@Nullable String json) {
+ if (json == null || json.isEmpty()) return Collections.emptyList();
+ return gson.fromJson(json, LIST_FILE);
+ }
+}
\ No newline at end of file
diff --git a/android_app/app/src/main/java/com/asp/android_app/caching/utils/LabelMappers.java b/android_app/app/src/main/java/com/asp/android_app/caching/utils/LabelMappers.java
new file mode 100644
index 00000000..ceae2e5a
--- /dev/null
+++ b/android_app/app/src/main/java/com/asp/android_app/caching/utils/LabelMappers.java
@@ -0,0 +1,29 @@
+package com.asp.android_app.caching.utils;
+
+import com.asp.android_app.caching.entities.LabelEntity;
+import com.asp.android_app.model.Label;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * Tiny mapper between network Label and Room LabelEntity.
+ */
+public final class LabelMappers {
+ private LabelMappers(){}
+
+ public static LabelEntity toEntity(Label l) {
+ LabelEntity e = new LabelEntity();
+ e.id = l.getId();
+ e.name = l.getName();
+ e.parent = l.getParent();
+ return e;
+ }
+
+ public static List toEntities(List ls) {
+ List out = new ArrayList<>();
+ if (ls == null) return out;
+ for (Label l : ls) out.add(toEntity(l));
+ return out;
+ }
+}
diff --git a/android_app/app/src/main/java/com/asp/android_app/caching/utils/MailMappers.java b/android_app/app/src/main/java/com/asp/android_app/caching/utils/MailMappers.java
new file mode 100644
index 00000000..0352c3c3
--- /dev/null
+++ b/android_app/app/src/main/java/com/asp/android_app/caching/utils/MailMappers.java
@@ -0,0 +1,144 @@
+package com.asp.android_app.caching.utils;
+
+import com.asp.android_app.caching.entities.MailEntity;
+import com.asp.android_app.caching.entities.UserLite;
+import com.asp.android_app.model.Label;
+import com.asp.android_app.model.Mail;
+import com.asp.android_app.model.response.File;
+import com.asp.android_app.model.response.UserInfo;
+import com.google.gson.Gson;
+
+import java.time.Instant;
+import java.time.format.DateTimeParseException;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.stream.Collectors;
+
+/**
+ * Conversions between network models and Room entities.
+ * Tip: we parse ISO dates into epoch ms for fast sorting (fallback to 0 on parse errors).
+ */
+public final class MailMappers {
+ private static final Gson gson = new Gson();
+
+ private MailMappers() {}
+
+ public static MailEntity toEntity(Mail m) {
+ MailEntity e = new MailEntity();
+ e.id = m.getId();
+ e.ownerId = m.getSender() != null ? m.getSender().getId() : 0;
+
+ // sender
+ UserInfo from = m.getSender();
+ e.fromId = (from != null ? from.getId() : 0);
+ e.fromName = (from != null ? from.getFullName() : "");
+ e.fromEmail = (from != null ? from.getMail() : "");
+ e.fromImageUrl = (from != null ? from.getImageUrl() : "");
+
+ e.subject = m.getSubject();
+ e.body = m.getBody();
+
+ e.sentAtRaw = m.getSentAt();
+ e.sentAtEpoch = parseIsoToEpoch(m.getSentAt());
+
+ // Handle createdAt properly
+ try {
+ java.lang.reflect.Method getCreatedAt = m.getClass().getMethod("getCreatedAt");
+ Object raw = getCreatedAt.invoke(m);
+ e.createdAtRaw = raw != null ? raw.toString() : "";
+ e.createdAtEpoch = parseIsoToEpoch(e.createdAtRaw);
+ } catch (Exception ignore) {
+ e.createdAtRaw = e.sentAtRaw; // fallback
+ e.createdAtEpoch = e.sentAtEpoch;
+ }
+
+ e.isDraft = m.isDraft();
+ e.isRead = m.isRead();
+ e.isStarred = m.isStarred();
+ e.isTrashed = m.isTrashed();
+ e.isSpam = m.isSpam();
+
+ // recipients (UserLite)
+ List recips = new ArrayList<>();
+ if (m.getSentTo() != null) {
+ for (UserInfo u : m.getSentTo()) {
+ recips.add(new UserLite(u.getId(), u.getMail(), u.getFullName(), u.getImageUrl()));
+ }
+ }
+ e.recipientsJson = gson.toJson(recips);
+
+ // FIXED: Store complete File objects instead of just names
+ e.attachmentsJson = gson.toJson(m.getAttachments() != null ? m.getAttachments() : new ArrayList<>());
+
+ // default LRU timestamp now
+ e.lastAccessEpoch = System.currentTimeMillis();
+ return e;
+ }
+
+ /**
+ * Convert MailEntity back to Mail object with complete attachment information
+ */
+ public static Mail fromEntity(MailEntity e) {
+ Mail m = new Mail();
+ m.setId(e.id);
+
+ // Reconstruct sender
+ UserInfo sender = new UserInfo(e.fromEmail, e.fromName);
+ try {
+ java.lang.reflect.Field fId = sender.getClass().getDeclaredField("id");
+ fId.setAccessible(true);
+ fId.set(sender, e.fromId);
+ java.lang.reflect.Field fImg = sender.getClass().getDeclaredField("image");
+ fImg.setAccessible(true);
+ fImg.set(sender, e.fromImageUrl);
+ } catch (Exception ignore) {}
+
+ m.setSubject(e.subject);
+ m.setBody(e.body);
+
+ // Set dates
+ try {
+ java.lang.reflect.Field fSentAt = m.getClass().getDeclaredField("sentAt");
+ fSentAt.setAccessible(true);
+ fSentAt.set(m, e.sentAtRaw);
+ java.lang.reflect.Field fCreatedAt = m.getClass().getDeclaredField("createdAt");
+ fCreatedAt.setAccessible(true);
+ fCreatedAt.set(m, e.createdAtRaw);
+ java.lang.reflect.Field fFrom = m.getClass().getDeclaredField("from");
+ fFrom.setAccessible(true);
+ fFrom.set(m, sender);
+ } catch (Exception ignore) {}
+
+ // flags
+ m.setIsRead(e.isRead);
+ m.setStarred(e.isStarred);
+ m.setTrashed(e.isTrashed);
+ m.setSpam(e.isSpam);
+ m.setIsDraft(e.isDraft);
+
+ // FIXED: Reconstruct complete attachments from JSON
+ try {
+ java.lang.reflect.Type fileListType = new com.google.gson.reflect.TypeToken>(){}.getType();
+ List attachments = gson.fromJson(e.attachmentsJson, fileListType);
+ m.setAttachments(attachments != null ? attachments : new ArrayList<>());
+ } catch (Exception ignore) {
+ m.setAttachments(new ArrayList<>());
+ }
+
+ return m;
+ }
+
+ public static List labelIds(List labels) {
+ if (labels == null) return new ArrayList<>();
+ return labels.stream().map(Label::getId).collect(Collectors.toList());
+ }
+
+ private static long parseIsoToEpoch(String iso) {
+ if (iso == null || iso.isEmpty()) return 0L;
+ try {
+ return Instant.parse(iso).toEpochMilli();
+ } catch (DateTimeParseException ignored) {
+ return 0L;
+ }
+ }
+}
\ No newline at end of file
diff --git a/android_app/app/src/main/java/com/asp/android_app/model/Label.java b/android_app/app/src/main/java/com/asp/android_app/model/Label.java
new file mode 100644
index 00000000..a64b725e
--- /dev/null
+++ b/android_app/app/src/main/java/com/asp/android_app/model/Label.java
@@ -0,0 +1,45 @@
+package com.asp.android_app.model;
+
+import androidx.annotation.Nullable;
+
+/**
+ * Represents a label returned from the backend.
+ * Includes ID, name, and an optional parent's ID.
+ */
+public class Label {
+
+ private int id;
+ private String name;
+ private Integer parent; // null means no parent
+
+ /**
+ * @return label's id
+ */
+ public int getId() {
+ return id;
+ }
+
+ /**
+ * @return label's name
+ */
+ public String getName() {
+ return name;
+ }
+
+ /**
+ * @return label's parent id, if there's no parent - null
+ */
+ public Integer getParent() {
+ return parent;
+ }
+
+ @Override
+ public boolean equals(@Nullable Object obj) {
+ return obj instanceof Label && this.getId() == ((Label) obj).getId();
+ }
+
+ @Override
+ public int hashCode() {
+ return Integer.hashCode(getId());
+ }
+}
diff --git a/android_app/app/src/main/java/com/asp/android_app/model/Mail.java b/android_app/app/src/main/java/com/asp/android_app/model/Mail.java
new file mode 100644
index 00000000..0b9a6327
--- /dev/null
+++ b/android_app/app/src/main/java/com/asp/android_app/model/Mail.java
@@ -0,0 +1,190 @@
+package com.asp.android_app.model;
+
+import com.asp.android_app.model.response.File;
+import com.asp.android_app.model.response.UserInfo;
+
+import java.util.List;
+
+/**
+ * Represents an email message.
+ * Used in:
+ * - GET /mails
+ * - GET /mails/:id
+ * - PATCH /mails/:id (to update read/starred/trash status)
+ */
+public class Mail {
+ private int id;
+ private int owner;
+ private UserInfo from;
+ private List sentTo;
+ private String subject;
+ private String body;
+ private String sentAt;
+ private String createdAt;
+ private boolean isDraft;
+ private boolean isRead;
+ private boolean isStarred;
+ private boolean isTrashed;
+ private boolean isSpam;
+ private List files;
+ private List labels;
+
+ /**
+ * @return the unique ID of the mail
+ */
+ public int getId() {
+ return id;
+ }
+
+ /**
+ * @param id the unique ID of the mail
+ */
+ public void setId(int id) {
+ this.id = id;
+ }
+
+ /**
+ * @return the sender user object
+ */
+ public UserInfo getSender() {
+ return from;
+ }
+
+ /**
+ * @return list of users the mail was sent to
+ */
+ public List getSentTo() {
+ return sentTo;
+ }
+
+ public void setSentTo(List sentTo) {
+ this.sentTo = sentTo;
+ }
+
+ /**
+ * @return the subject of the mail
+ */
+ public String getSubject() {
+ return subject;
+ }
+
+ /**
+ * @param subject the subject to set
+ */
+ public void setSubject(String subject) {
+ this.subject = subject;
+ }
+
+ /**
+ * @return the body content of the mail
+ */
+ public String getBody() {
+ return body;
+ }
+
+ /**
+ * @param body the content of the mail
+ */
+ public void setBody(String body) {
+ this.body = body;
+ }
+
+ /**
+ * @return timestamp string of when the mail was sent
+ */
+ public String getSentAt() {
+ return sentAt;
+ }
+
+ /**
+ * @return true if the mail is starred
+ */
+ public boolean isStarred() {
+ return isStarred;
+ }
+
+ /**
+ * @param starred whether the mail is starred
+ */
+ public void setStarred(boolean starred) {
+ isStarred = starred;
+ }
+
+ /**
+ * @return true if the mail is in the trash
+ */
+ public boolean isTrashed() {
+ return isTrashed;
+ }
+
+ /**
+ * @param trashed whether the mail is in the trash
+ */
+ public void setTrashed(boolean trashed) {
+ isTrashed = trashed;
+ }
+
+ /**
+ * @return true if the mail is marked as spam
+ */
+ public boolean isSpam() {
+ return isSpam;
+ }
+
+ /**
+ * @return true if the mail is marked as read, otherwise false
+ */
+ public boolean isRead() {
+ return isRead;
+ }
+
+ /**
+ * @param isRead update the is read flag
+ */
+ public void setIsRead(boolean isRead) {
+ this.isRead = isRead;
+ }
+
+ /**
+ * @param spam whether the mail is marked as spam
+ */
+ public void setSpam(boolean spam) {
+ isSpam = spam;
+ }
+
+ /**
+ * @return true if the mail is marked as a draft, otherwise false
+ */
+ public boolean isDraft() {
+ return isDraft;
+ }
+
+ public void setIsDraft(boolean isDraft) {
+ this.isDraft = isDraft;
+ }
+
+ /**
+ * @return list of files attached to mail
+ */
+ public List getAttachments() {
+ return files;
+ }
+
+ public void setAttachments(List files) {
+ this.files = files;
+ }
+
+ /**
+ * @return list of labels objects the mail is marked with
+ */
+ public List getLabels() {
+ return labels;
+ }
+
+ /**
+ * @param labels new list of labels to set for a mail
+ */
+ public void setLabels(List labels) {
+ this.labels = labels;
+ }
+}
\ No newline at end of file
diff --git a/android_app/app/src/main/java/com/asp/android_app/model/User.java b/android_app/app/src/main/java/com/asp/android_app/model/User.java
new file mode 100644
index 00000000..06223dbe
--- /dev/null
+++ b/android_app/app/src/main/java/com/asp/android_app/model/User.java
@@ -0,0 +1,112 @@
+package com.asp.android_app.model;
+
+/**
+ * Represents a user used for registration and login operations.
+ * Used in:
+ * - POST /users (registration)
+ * - Passed as part of LoginRequest (email and password)
+ */
+public class User {
+ /**
+ * Full name of the user.
+ */
+ private String fullName;
+
+ /**
+ * Email address of the user.
+ */
+ private String mail;
+
+ /**
+ * Password chosen by the user.
+ */
+ private String password;
+
+ /**
+ * Date of birth of the user.
+ */
+ private final String dateOfBirth;
+
+ /**
+ * Image in base64 format
+ */
+ private final String image;
+
+ /**
+ * Constructs a new User for registration.
+ *
+ * @param fullName the full name of the user
+ * @param mail the user's email address
+ * @param password the user's password
+ */
+ public User(String fullName, String mail, String password) {
+ this(fullName, mail, password, "", "");
+ }
+
+ /**
+ * Constructs a new User for registration.
+ *
+ * @param fullName the full name of the user
+ * @param mail the user's email address
+ * @param password the user's password
+ * @param date user's date of birth
+ * @param image user's image in base64
+ */
+ public User(String fullName, String mail, String password, String date, String image) {
+ this.fullName = fullName;
+ this.mail = mail;
+ this.password = password;
+ this.dateOfBirth = date;
+ this.image = image;
+ }
+
+ /**
+ * @return the full name of the user
+ */
+ public String getFullName() {
+ return fullName;
+ }
+
+ /**
+ * @param fullName the full name to set
+ */
+ public void setFullName(String fullName) {
+ this.fullName = fullName;
+ }
+
+ /**
+ * @return the email address of the user
+ */
+ public String getMail() {
+ return mail;
+ }
+
+ public String getDateOfBirth() {
+ return this.dateOfBirth;
+ }
+
+ public String getImage() {
+ return this.image;
+ }
+
+ /**
+ * @param mail the email address to set
+ */
+ public void setMail(String mail) {
+ this.mail = mail;
+ }
+
+ /**
+ * @return the user's password
+ */
+ public String getPassword() {
+ return password;
+ }
+
+ /**
+ * @param password the password to set
+ */
+ public void setPassword(String password) {
+ this.password = password;
+ }
+}
\ No newline at end of file
diff --git a/android_app/app/src/main/java/com/asp/android_app/model/request/EditMailRequest.java b/android_app/app/src/main/java/com/asp/android_app/model/request/EditMailRequest.java
new file mode 100644
index 00000000..ed306c07
--- /dev/null
+++ b/android_app/app/src/main/java/com/asp/android_app/model/request/EditMailRequest.java
@@ -0,0 +1,44 @@
+package com.asp.android_app.model.request;
+
+import com.asp.android_app.model.Label;
+
+import java.util.List;
+
+public class EditMailRequest {
+ private final Boolean isRead;
+ private final Boolean isStarred;
+ private final Boolean isTrashed;
+ private final List labels;
+
+ public Boolean getRead() {
+ return isRead;
+ }
+
+ public Boolean getStarred() {
+ return isStarred;
+ }
+
+ public Boolean getTrashed() {
+ return isTrashed;
+ }
+
+ public List getLabels() {
+ return labels;
+ }
+
+ /**
+ * Creates a edit mail request object, marks what fields to edit.
+ * fields that shouldn't be changed should receive value null
+ *
+ * @param isRead true if the flag should be marked as read
+ * @param isStar new star flag value
+ * @param isTrash true if should move to trash/ delete forever, false if restoring mail
+ * @param labels list of labels new ids to replace the old values
+ */
+ public EditMailRequest(Boolean isRead, Boolean isStar, Boolean isTrash, List labels) {
+ this.isRead = isRead;
+ this.isStarred = isStar;
+ this.isTrashed = isTrash;
+ this.labels = labels;
+ }
+}
\ No newline at end of file
diff --git a/android_app/app/src/main/java/com/asp/android_app/model/request/LabelRequest.java b/android_app/app/src/main/java/com/asp/android_app/model/request/LabelRequest.java
new file mode 100644
index 00000000..c248fcc5
--- /dev/null
+++ b/android_app/app/src/main/java/com/asp/android_app/model/request/LabelRequest.java
@@ -0,0 +1,30 @@
+package com.asp.android_app.model.request;
+
+/**
+ * Request body for creating or editing a label.
+ * Sent to the server with the label's name.
+ */
+public class LabelRequest {
+ private String name;
+
+ /**
+ * @param name new name for the label
+ */
+ public LabelRequest(String name) {
+ this.name = name;
+ }
+
+ /**
+ * @return new name that will be or just been set for the label
+ */
+ public String getName() {
+ return name;
+ }
+
+ /**
+ * @param name new name to be set for the label
+ */
+ public void setName(String name) {
+ this.name = name;
+ }
+}
diff --git a/android_app/app/src/main/java/com/asp/android_app/model/request/LoginRequest.java b/android_app/app/src/main/java/com/asp/android_app/model/request/LoginRequest.java
new file mode 100644
index 00000000..e85cb949
--- /dev/null
+++ b/android_app/app/src/main/java/com/asp/android_app/model/request/LoginRequest.java
@@ -0,0 +1,50 @@
+package com.asp.android_app.model.request;
+
+/**
+ * Request body for user login.
+ * Used in:
+ * - POST /tokens
+ */
+public class LoginRequest {
+ private String mail;
+ private String password;
+
+ /**
+ * Constructs a login request.
+ *
+ * @param mail user's email address
+ * @param password user's password
+ */
+ public LoginRequest(String mail, String password) {
+ this.mail = mail;
+ this.password = password;
+ }
+
+ /**
+ * @return email address of the user
+ */
+ public String getMail() {
+ return mail;
+ }
+
+ /**
+ * @param mail email address to set
+ */
+ public void setMail(String mail) {
+ this.mail = mail;
+ }
+
+ /**
+ * @return password of the user
+ */
+ public String getPassword() {
+ return password;
+ }
+
+ /**
+ * @param password password to set
+ */
+ public void setPassword(String password) {
+ this.password = password;
+ }
+}
\ No newline at end of file
diff --git a/android_app/app/src/main/java/com/asp/android_app/model/request/ProfileImageRequest.java b/android_app/app/src/main/java/com/asp/android_app/model/request/ProfileImageRequest.java
new file mode 100644
index 00000000..3cb78219
--- /dev/null
+++ b/android_app/app/src/main/java/com/asp/android_app/model/request/ProfileImageRequest.java
@@ -0,0 +1,34 @@
+package com.asp.android_app.model.request;
+
+
+/**
+ * Request body for changing profile image.
+ * Used in:
+ * - PATCH /users/{id}
+ */
+public class ProfileImageRequest {
+ private String image;
+
+ /**
+ * Constructs a request with a base64 image string.
+ *
+ * @param image base64-encoded image string
+ */
+ public ProfileImageRequest(String image) {
+ this.image = image;
+ }
+
+ /**
+ * @return base64 image string
+ */
+ public String getImage() {
+ return image;
+ }
+
+ /**
+ * @param image base64 image string to set
+ */
+ public void setImage(String image) {
+ this.image = image;
+ }
+}
\ No newline at end of file
diff --git a/android_app/app/src/main/java/com/asp/android_app/model/request/RegisterRequest.java b/android_app/app/src/main/java/com/asp/android_app/model/request/RegisterRequest.java
new file mode 100644
index 00000000..8b4f2884
--- /dev/null
+++ b/android_app/app/src/main/java/com/asp/android_app/model/request/RegisterRequest.java
@@ -0,0 +1,39 @@
+package com.asp.android_app.model.request;
+
+import com.asp.android_app.model.User;
+import com.google.gson.annotations.SerializedName;
+
+public class RegisterRequest {
+
+ @SerializedName("fullName")
+ private String fullName;
+
+ @SerializedName("mail")
+ private String mail;
+
+ @SerializedName("password")
+ private String password;
+
+ @SerializedName("dateOfBirth")
+ private String dateOfBirth;
+
+ @SerializedName("image")
+ private String image;
+
+ public RegisterRequest(String fullName, String mail, String password, String dateOfBirth, String image) {
+ this.fullName = fullName;
+ this.mail = mail;
+ this.password = password;
+ this.dateOfBirth = dateOfBirth;
+ this.image = image;
+ }
+
+ public RegisterRequest(User user) {
+ this.fullName = user.getFullName();
+ this.mail = user.getMail();
+ this.password = user.getPassword();
+ this.dateOfBirth = user.getDateOfBirth();
+ this.image = user.getImage();
+ }
+
+}
diff --git a/android_app/app/src/main/java/com/asp/android_app/model/request/SendMailRequest.java b/android_app/app/src/main/java/com/asp/android_app/model/request/SendMailRequest.java
new file mode 100644
index 00000000..53e9eedc
--- /dev/null
+++ b/android_app/app/src/main/java/com/asp/android_app/model/request/SendMailRequest.java
@@ -0,0 +1,24 @@
+package com.asp.android_app.model.request;
+
+import com.asp.android_app.model.response.File;
+
+import java.util.List;
+
+/**
+ * Minimal request we use for saving drafts or sending mails (using POST)
+ */
+public class SendMailRequest {
+ private String subject;
+ private String body;
+ private List sentTo;
+ private boolean saveAsDraft;
+ private List files;
+
+ public SendMailRequest(String subject, String body, List sentTo, boolean saveAsDraft, List files) {
+ this.subject = subject;
+ this.body = body;
+ this.sentTo = sentTo;
+ this.saveAsDraft = saveAsDraft;
+ this.files = files;
+ }
+}
diff --git a/android_app/app/src/main/java/com/asp/android_app/model/request/SpamRequest.java b/android_app/app/src/main/java/com/asp/android_app/model/request/SpamRequest.java
new file mode 100644
index 00000000..a398f8c9
--- /dev/null
+++ b/android_app/app/src/main/java/com/asp/android_app/model/request/SpamRequest.java
@@ -0,0 +1,44 @@
+package com.asp.android_app.model.request;
+
+/**
+ * Request model used to mark or unmark a mail as spam.
+ * Used in:
+ * - POST /blacklist (to mark as spam)
+ * - DELETE /blacklist (to remove from spam)
+ */
+public class SpamRequest {
+ private int mailId;
+ private boolean isPreviouslySpam;
+
+ /**
+ * Constructs a request for marking or unmarking spam.
+ *
+ * @param mailId ID of the mail being reported or unreported as spam
+ */
+ public SpamRequest(int mailId, boolean isPreviouslySpam) {
+ this.mailId = mailId;
+ this.isPreviouslySpam = isPreviouslySpam;
+ }
+
+ /**
+ * @return ID of the mail
+ */
+ public int getMailId() {
+ return mailId;
+ }
+
+ /**
+ * @param mailId ID of the mail to set
+ */
+ public void setMailId(int mailId) {
+ this.mailId = mailId;
+ }
+
+ public boolean getIsPreviouslySpam() {
+ return isPreviouslySpam;
+ }
+
+ public void setIsPreviouslySpam(boolean isSpam) {
+ this.isPreviouslySpam = isSpam;
+ }
+}
\ No newline at end of file
diff --git a/android_app/app/src/main/java/com/asp/android_app/model/response/AuthResponse.java b/android_app/app/src/main/java/com/asp/android_app/model/response/AuthResponse.java
new file mode 100644
index 00000000..ccb93965
--- /dev/null
+++ b/android_app/app/src/main/java/com/asp/android_app/model/response/AuthResponse.java
@@ -0,0 +1,35 @@
+package com.asp.android_app.model.response;
+
+/**
+ * Represents the response returned after a successful login or registration.
+ * Used in:
+ * - POST /tokens
+ * - POST /users
+ */
+public class AuthResponse {
+
+ /**
+ * JWT token assigned to the authenticated user.
+ */
+ private String token;
+
+ /**
+ * User object returned in the JSON from the backend
+ */
+ private UserInfo user;
+
+ /**
+ * @return JWT token returned by the server
+ */
+ public String getToken() {
+ return token;
+ }
+
+ /**
+ * @return user object from the server
+ */
+ public UserInfo getUser() {
+ return user;
+ }
+
+}
\ No newline at end of file
diff --git a/android_app/app/src/main/java/com/asp/android_app/model/response/File.java b/android_app/app/src/main/java/com/asp/android_app/model/response/File.java
new file mode 100644
index 00000000..f02038c6
--- /dev/null
+++ b/android_app/app/src/main/java/com/asp/android_app/model/response/File.java
@@ -0,0 +1,88 @@
+package com.asp.android_app.model.response;
+
+import android.os.Parcelable;
+
+/**
+ * Class to represent an attachment (file) sent in mails
+ */
+public class File implements Parcelable {
+
+ /**
+ * File name
+ */
+ private String name;
+
+ /**
+ * File data in base64 format
+ */
+ private String data;
+
+ public File() {
+ }
+
+ public File(String name, String data) {
+ this.name = name;
+ this.data = data;
+ }
+
+ public String getName() {
+ return name;
+ }
+
+ public String getData() {
+ return data;
+ }
+
+ public boolean isImage() {
+ return name != null && name.matches("(?i).+\\.(png|jpg|jpeg|gif|bmp|webp)$");
+ }
+
+ /**
+ * Equals by name+data to prevent duplicates in the chip list (good enough for now)
+ */
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) return true;
+ if (!(o instanceof File)) return false;
+ File that = (File) o;
+ return name != null && name.equals(that.name)
+ && data != null && data.equals(that.data);
+ }
+
+ @Override
+ public int hashCode() {
+ int r = name != null ? name.hashCode() : 0;
+ r = 31 * r + (data != null ? data.hashCode() : 0);
+ return r;
+ }
+
+
+ // ---- Parcelable bits ----
+ protected File(android.os.Parcel in) {
+ name = in.readString();
+ data = in.readString();
+ }
+
+ public static final Creator CREATOR = new Creator() {
+ @Override
+ public File createFromParcel(android.os.Parcel in) {
+ return new File(in);
+ }
+
+ @Override
+ public File[] newArray(int size) {
+ return new File[size];
+ }
+ };
+
+ @Override
+ public int describeContents() {
+ return 0;
+ }
+
+ @Override
+ public void writeToParcel(android.os.Parcel dest, int flags) {
+ dest.writeString(name);
+ dest.writeString(data);
+ }
+}
\ No newline at end of file
diff --git a/android_app/app/src/main/java/com/asp/android_app/model/response/MailListResponse.java b/android_app/app/src/main/java/com/asp/android_app/model/response/MailListResponse.java
new file mode 100644
index 00000000..5ec22b61
--- /dev/null
+++ b/android_app/app/src/main/java/com/asp/android_app/model/response/MailListResponse.java
@@ -0,0 +1,17 @@
+package com.asp.android_app.model.response;
+
+import com.asp.android_app.model.Mail;
+
+import java.util.List;
+
+public class MailListResponse {
+ private List mails;
+
+ public List getMails() {
+ return mails;
+ }
+
+ public void setMails(List mails) {
+ this.mails = mails;
+ }
+}
\ No newline at end of file
diff --git a/android_app/app/src/main/java/com/asp/android_app/model/response/ReadStatus.java b/android_app/app/src/main/java/com/asp/android_app/model/response/ReadStatus.java
new file mode 100644
index 00000000..37f86633
--- /dev/null
+++ b/android_app/app/src/main/java/com/asp/android_app/model/response/ReadStatus.java
@@ -0,0 +1,34 @@
+package com.asp.android_app.model.response;
+
+
+/**
+ * Request body for updating the "read" status of a mail.
+ * Used in:
+ * - PATCH /mails/{id}
+ */
+public class ReadStatus {
+ private boolean isRead;
+
+ /**
+ * Constructs a request to mark a mail as read.
+ *
+ * @param isRead true if mail should be marked as read
+ */
+ public ReadStatus(boolean isRead) {
+ this.isRead = isRead;
+ }
+
+ /**
+ * @return true if the mail is marked as read
+ */
+ public boolean isRead() {
+ return isRead;
+ }
+
+ /**
+ * @param isRead whether to mark the mail as read
+ */
+ public void setRead(boolean isRead) {
+ this.isRead = isRead;
+ }
+}
\ No newline at end of file
diff --git a/android_app/app/src/main/java/com/asp/android_app/model/response/UserInfo.java b/android_app/app/src/main/java/com/asp/android_app/model/response/UserInfo.java
new file mode 100644
index 00000000..416df027
--- /dev/null
+++ b/android_app/app/src/main/java/com/asp/android_app/model/response/UserInfo.java
@@ -0,0 +1,148 @@
+package com.asp.android_app.model.response;
+
+import android.os.Parcel;
+import android.os.Parcelable;
+
+import androidx.annotation.NonNull;
+
+/**
+ * Represents basic public profile information for a user.
+ * Used in:
+ * - GET /users/{id}
+ */
+public class UserInfo implements Parcelable {
+
+ /**
+ * User id in the backend
+ */
+ private final int id;
+
+ /**
+ * Mail address of a user
+ */
+ private final String mail;
+
+ /**
+ * The full name of the user.
+ */
+ private String fullName;
+
+ /**
+ * The URL to the user's profile image.
+ */
+ private String image;
+
+ /**
+ * Birthday of a user
+ */
+ private final String dateOfBirth;
+
+ public UserInfo(String mail, String fullName) {
+ this.id = -1;
+ this.mail = mail;
+ this.fullName = fullName;
+ this.image = "";
+ this.dateOfBirth = "";
+ }
+
+ public UserInfo(UserSearchResult u) {
+ this.id = u.getId();
+ this.mail = u.getMail();
+ this.fullName = u.getName();
+ dateOfBirth = "";
+ image = "";
+ }
+
+ // Constructor from Parcel
+ protected UserInfo(Parcel in) {
+ id = in.readInt();
+ mail = in.readString();
+ fullName = in.readString();
+ image = in.readString();
+ dateOfBirth = in.readString();
+ }
+
+ /**
+ * @return id of the user
+ */
+ public int getId() {
+ return id;
+ }
+
+ /**
+ * @return mail address of the user
+ */
+ public String getMail() {
+ return mail;
+ }
+
+ /**
+ * @return full name of the user
+ */
+ public String getFullName() {
+ return fullName;
+ }
+
+ /**
+ * @param fullName full name to set
+ */
+ public void setFullName(String fullName) {
+ this.fullName = fullName;
+ }
+
+ /**
+ * @return profile image URL
+ */
+ public String getImageUrl() {
+ return image;
+ }
+
+ /**
+ * @param imageUrl URL of the profile image
+ */
+ public void setImageUrl(String imageUrl) {
+ this.image = imageUrl;
+ }
+
+ /**
+ * @return user's date of birth
+ */
+ public String getDateOfBirth() {
+ return this.dateOfBirth;
+ }
+
+ public static final Creator CREATOR = new Creator() {
+ @Override
+ public UserInfo createFromParcel(Parcel in) {
+ return new UserInfo(in);
+ }
+
+ @Override
+ public UserInfo[] newArray(int size) {
+ return new UserInfo[size];
+ }
+ };
+
+ @Override
+ public int describeContents() {
+ return 0;
+ }
+
+ @Override
+ public void writeToParcel(Parcel parcel, int flags) {
+ parcel.writeInt(id);
+ parcel.writeString(mail);
+ parcel.writeString(fullName);
+ parcel.writeString(image);
+ parcel.writeString(dateOfBirth);
+ }
+
+ @NonNull
+ @Override
+ public String toString() {
+ // Used if the view tries to setText after selection.
+ // We immediately clear the field on selection, but keep this clean.
+ return fullName + " <" + mail + ">";
+ }
+
+}
\ No newline at end of file
diff --git a/android_app/app/src/main/java/com/asp/android_app/model/response/UserSearchResult.java b/android_app/app/src/main/java/com/asp/android_app/model/response/UserSearchResult.java
new file mode 100644
index 00000000..aeab43e0
--- /dev/null
+++ b/android_app/app/src/main/java/com/asp/android_app/model/response/UserSearchResult.java
@@ -0,0 +1,66 @@
+package com.asp.android_app.model.response;
+
+/**
+ * Represents a single result item when searching for users by email.
+ * Used in:
+ * - GET /users/search?email=...
+ */
+public class UserSearchResult {
+
+ /**
+ * Unique ID of the user.
+ */
+ private int id;
+
+ /**
+ * Full name of the user.
+ */
+ private String name;
+
+ /**
+ * Email address of the user.
+ */
+ private String mail;
+
+ /**
+ * @return unique ID of the user
+ */
+ public int getId() {
+ return id;
+ }
+
+ /**
+ * @param id unique user ID to assign
+ */
+ public void setId(int id) {
+ this.id = id;
+ }
+
+ /**
+ * @return full name of the user
+ */
+ public String getName() {
+ return name;
+ }
+
+ /**
+ * @param fullName full name to assign
+ */
+ public void setFullName(String fullName) {
+ this.name = fullName;
+ }
+
+ /**
+ * @return email address of the user
+ */
+ public String getMail() {
+ return mail;
+ }
+
+ /**
+ * @param mail email address to assign
+ */
+ public void setMail(String mail) {
+ this.mail = mail;
+ }
+}
\ No newline at end of file
diff --git a/android_app/app/src/main/java/com/asp/android_app/repository/LabelRepository.java b/android_app/app/src/main/java/com/asp/android_app/repository/LabelRepository.java
new file mode 100644
index 00000000..713f9566
--- /dev/null
+++ b/android_app/app/src/main/java/com/asp/android_app/repository/LabelRepository.java
@@ -0,0 +1,147 @@
+package com.asp.android_app.repository;
+
+import android.content.Context;
+import android.util.Log;
+
+import androidx.lifecycle.LiveData;
+import androidx.lifecycle.MutableLiveData;
+
+import com.asp.android_app.api.LabelApi;
+import com.asp.android_app.api.ApiClient;
+import com.asp.android_app.model.Label;
+import com.asp.android_app.model.request.LabelRequest;
+
+import java.util.List;
+
+import retrofit2.Call;
+import retrofit2.Callback;
+import retrofit2.Response;
+
+/**
+ * Repository class responsible for handling label-related data operations.
+ * Responsible for calling the LabelApi and exposing results via LiveData.
+ */
+public class LabelRepository {
+
+ private final LabelApi labelApi;
+
+ public LabelRepository(Context context) {
+ this.labelApi = ApiClient.getClient(context).create(LabelApi.class);
+ }
+
+ /**
+ * Fetches all labels for the authenticated user.
+ *
+ * @return LiveData list of labels.
+ */
+ public LiveData> getAllLabels() {
+ MutableLiveData> labelsLiveData = new MutableLiveData<>();
+ labelApi.getAllLabels().enqueue(new Callback<>() {
+ @Override
+ public void onResponse(Call> call, Response> response) {
+ Log.i("LABELS ALL", response.code() + " " + response.errorBody());
+ if (response.isSuccessful()) {
+ labelsLiveData.setValue(response.body());
+ } else {
+ labelsLiveData.setValue(null);
+ }
+ }
+
+ @Override
+ public void onFailure(Call> call, Throwable t) {
+ labelsLiveData.setValue(null);
+ }
+ });
+ return labelsLiveData;
+ }
+
+ /**
+ * Creates a new root-level label.
+ *
+ * @param name Name of the label.
+ * @return LiveData containing the created label or null if failed.
+ */
+ public LiveData createLabel(String name) {
+ MutableLiveData result = new MutableLiveData<>();
+ labelApi.createLabel(new LabelRequest(name)).enqueue(new Callback() {
+ @Override
+ public void onResponse(Call call, Response response) {
+ result.setValue(response.isSuccessful() ? response.body() : null);
+ }
+
+ @Override
+ public void onFailure(Call call, Throwable t) {
+ result.setValue(null);
+ }
+ });
+ return result;
+ }
+
+ /**
+ * Creates a sub-label under the given parent label.
+ *
+ * @param parentId ID of the parent label.
+ * @param name Name of the new sub-label.
+ * @return LiveData containing the created sub-label or null if failed.
+ */
+ public LiveData createSublabel(int parentId, String name) {
+ MutableLiveData result = new MutableLiveData<>();
+ labelApi.createSublabel(parentId, new LabelRequest(name)).enqueue(new Callback() {
+ @Override
+ public void onResponse(Call call, Response response) {
+ result.setValue(response.isSuccessful() ? response.body() : null);
+ }
+
+ @Override
+ public void onFailure(Call call, Throwable t) {
+ result.setValue(null);
+ }
+ });
+ return result;
+ }
+
+ /**
+ * Renames an existing label by ID.
+ *
+ * @param labelId ID of the label to rename.
+ * @param newName New name for the label.
+ * @return LiveData true if successful, false otherwise.
+ */
+ public LiveData editLabel(int labelId, String newName) {
+ MutableLiveData result = new MutableLiveData<>();
+ labelApi.editLabel(labelId, new LabelRequest(newName)).enqueue(new Callback<>() {
+ @Override
+ public void onResponse(Call call, Response response) {
+ result.setValue(response.isSuccessful());
+ }
+
+ @Override
+ public void onFailure(Call call, Throwable t) {
+ result.setValue(false);
+ }
+ });
+ return result;
+ }
+
+ /**
+ * Deletes a label by its ID.
+ *
+ * @param labelId ID of the label to delete.
+ * @return LiveData true if successful, false otherwise.
+ */
+ public LiveData deleteLabel(int labelId) {
+ MutableLiveData result = new MutableLiveData<>();
+ labelApi.deleteLabel(labelId).enqueue(new Callback() {
+ @Override
+ public void onResponse(Call call, Response response) {
+ result.setValue(response.isSuccessful());
+ }
+
+ @Override
+ public void onFailure(Call call, Throwable t) {
+ result.setValue(false);
+ }
+ });
+ return result;
+ }
+}
diff --git a/android_app/app/src/main/java/com/asp/android_app/repository/MailRepository.java b/android_app/app/src/main/java/com/asp/android_app/repository/MailRepository.java
new file mode 100644
index 00000000..583f0099
--- /dev/null
+++ b/android_app/app/src/main/java/com/asp/android_app/repository/MailRepository.java
@@ -0,0 +1,533 @@
+package com.asp.android_app.repository;
+
+import android.content.Context;
+
+import androidx.annotation.NonNull;
+import androidx.lifecycle.MutableLiveData;
+
+import com.asp.android_app.api.ApiClient;
+import com.asp.android_app.api.MailApi;
+import com.asp.android_app.caching.MailLocalDataSource;
+import com.asp.android_app.caching.entities.LabelEntity;
+import com.asp.android_app.caching.entities.MailEntity;
+import com.asp.android_app.caching.entities.MailLabelCrossRef;
+import com.asp.android_app.caching.entities.UserLite;
+import com.asp.android_app.caching.utils.LabelMappers;
+import com.asp.android_app.caching.utils.MailMappers;
+import com.asp.android_app.model.Label;
+import com.asp.android_app.model.Mail;
+import com.asp.android_app.model.request.EditMailRequest;
+import com.asp.android_app.model.request.SendMailRequest;
+import com.asp.android_app.model.request.SpamRequest;
+import com.asp.android_app.model.response.MailListResponse;
+import com.asp.android_app.model.response.UserInfo;
+import com.asp.android_app.utils.NetworkUtil;
+import com.asp.android_app.utils.Result;
+import com.google.gson.Gson;
+
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+
+import retrofit2.Call;
+import retrofit2.Callback;
+import retrofit2.Response;
+
+/**
+ * Repository class for handling mail-related data operations.
+ * 1. Exposes immediately cached (Room) data.
+ * 2. Will attempt calling the backend API to update cache and expose to user
+ * Rule: The backend is the real source of truth, data fetched from it will override localData data
+ */
+public class MailRepository {
+
+ private final MailApi mailApi;
+ private final MailLocalDataSource localData;
+ private final ExecutorService io;
+ private final Context context;
+
+ private final int MAIL_LIMIT = 50;
+
+ public MailRepository(Context context) {
+ this.context = context;
+ io = Executors.newSingleThreadExecutor();
+ mailApi = ApiClient.getClient(context).create(MailApi.class);
+ localData = new MailLocalDataSource(context);
+ }
+
+ /**
+ * Build a MailListResponse from localData entities for quick UI usage.
+ */
+ private MailListResponse mapLocalToListResponse(List entities) {
+ MailListResponse r = new MailListResponse();
+ r.setMails(mapEntitiesToNetwork(entities));
+ return r;
+ }
+
+ /**
+ * Converts Room MailEntity to network Mail (includes attachments and recipients).
+ * Now properly reconstructs attachments from cached JSON data.
+ *
+ * @return list of mails in Mail object instead of cache object
+ */
+ private List mapEntitiesToNetwork(List es) {
+ List out = new ArrayList<>();
+ for (MailEntity e : es) {
+ Mail m = new Mail();
+ m.setId(e.id);
+
+ // sender
+ UserInfo sender = new UserInfo(e.fromEmail, e.fromName);
+ try {
+ // tiny hack to set id + image since UserInfo fields are final/non-final mix
+ java.lang.reflect.Field fId = sender.getClass().getDeclaredField("id");
+ fId.setAccessible(true);
+ fId.set(sender, e.fromId);
+ java.lang.reflect.Field fImg = sender.getClass().getDeclaredField("image");
+ fImg.setAccessible(true);
+ fImg.set(sender, e.fromImageUrl);
+ } catch (Exception ignore) {
+ }
+ m.setSubject(e.subject);
+ m.setBody(e.body);
+ // raw dates back (UI formats)
+ try {
+ java.lang.reflect.Field fSentAt = m.getClass().getDeclaredField("sentAt");
+ fSentAt.setAccessible(true);
+ fSentAt.set(m, e.sentAtRaw);
+ java.lang.reflect.Field fCreatedAt = m.getClass().getDeclaredField("createdAt");
+ fCreatedAt.setAccessible(true);
+ fCreatedAt.set(m, e.createdAtRaw);
+ java.lang.reflect.Field fFrom = m.getClass().getDeclaredField("from");
+ fFrom.setAccessible(true);
+ fFrom.set(m, sender);
+ } catch (Exception ignore) {
+ }
+ // flags
+ m.setIsRead(e.isRead);
+ m.setStarred(e.isStarred);
+ m.setTrashed(e.isTrashed);
+ m.setSpam(e.isSpam);
+ m.setIsDraft(e.isDraft);
+
+ // FIXED: Reconstruct recipients from JSON
+ try {
+ java.lang.reflect.Type userLiteListType = new com.google.gson.reflect.TypeToken>(){}.getType();
+ List recipients = new Gson().fromJson(e.recipientsJson, userLiteListType);
+ if (recipients != null && !recipients.isEmpty()) {
+ List sentTo = new ArrayList<>();
+ for (UserLite lite : recipients) {
+ UserInfo recipient = new UserInfo(lite.mail, lite.fullName);
+ try {
+ java.lang.reflect.Field rId = recipient.getClass().getDeclaredField("id");
+ rId.setAccessible(true);
+ rId.set(recipient, lite.id);
+ java.lang.reflect.Field rImg = recipient.getClass().getDeclaredField("image");
+ rImg.setAccessible(true);
+ rImg.set(recipient, lite.imageUrl);
+ } catch (Exception ignore) {}
+ sentTo.add(recipient);
+ }
+ m.setSentTo(sentTo);
+ }
+ } catch (Exception ignore) {
+ m.setSentTo(new ArrayList<>());
+ }
+
+ // FIXED: Reconstruct complete attachments from JSON instead of skipping them
+ try {
+ java.lang.reflect.Type fileListType = new com.google.gson.reflect.TypeToken>(){}.getType();
+ List attachments = new Gson().fromJson(e.attachmentsJson, fileListType);
+ m.setAttachments(attachments != null ? attachments : new ArrayList<>());
+ } catch (Exception ignore) {
+ m.setAttachments(new ArrayList<>());
+ }
+
+ out.add(m);
+ }
+ return out;
+ }
+
+ private List idsOf(List es) {
+ List ids = new ArrayList<>();
+ for (MailEntity e : es) ids.add(e.id);
+ return ids;
+ }
+
+ private List getLocalByType(String inboxType) {
+ switch ((inboxType == null ? "incoming" : inboxType).toLowerCase()) {
+ case "sent":
+ return localData.getSent();
+ case "star":
+ return localData.getStarred();
+ case "draft":
+ return localData.getDrafts();
+ case "spam":
+ return localData.getSpam();
+ case "trash":
+ return localData.getTrash();
+ case "incoming":
+ case "all":
+ default:
+ return localData.getIncoming();
+ }
+ }
+
+ private void upsertServerMails(List mails) {
+ if (mails == null || mails.isEmpty()) return;
+
+ List entities = new ArrayList<>();
+ List refs = new ArrayList<>();
+
+ LinkedHashMap uniq = new LinkedHashMap<>();
+ for (Mail m : mails) {
+ MailEntity e = MailMappers.toEntity(m);
+ entities.add(e);
+
+ if (m.getLabels() != null) {
+ for (Label l : m.getLabels()) {
+ if (l == null) continue;
+ uniq.put(l.getId(), l);
+ refs.add(new MailLabelCrossRef(m.getId(), l.getId()));
+ }
+ }
+ }
+
+ // upsert labels **BEFORE** cross refs (this prevents a crash don't touch!)
+ List labelEntities = LabelMappers.toEntities(new ArrayList<>(uniq.values()));
+ localData.upsertLabels(labelEntities);
+ // now it's safe to upsert mails + cross refs thank god
+ localData.upsertMails(entities, refs);
+ }
+
+ /**
+ * Fetch mails by inbox type and page number.
+ *
+ * @param inboxType "incoming", "sent", "star", "trash", etc.
+ * @param page current page number
+ * @param resultLiveData a LiveData object to observe result (mail list)
+ */
+ public void getMailsByType(String inboxType, int page, MutableLiveData> resultLiveData) {
+ if (NetworkUtil.isOnline(context)) { // online - use backend
+ resultLiveData.postValue(new Result.Loading<>());
+
+ mailApi.getMailsByType(inboxType, page, MAIL_LIMIT).enqueue(new Callback<>() {
+ @Override
+ public void onResponse(@NonNull Call call, @NonNull Response resp) {
+ io.execute(() -> {
+ if (resp.isSuccessful() && resp.body() != null) {
+ // Update cache with fresh network data
+ updateCacheWithNetworkData(resp.body().getMails(), inboxType, page);
+ resultLiveData.postValue(new Result.Success<>(resp.body()));
+ //List freshData = getLocalByType(inboxType);
+ //resultLiveData.postValue(new Result.Success<>(mapLocalToListResponse(freshData)));
+ } else {
+ // Network failed, fall back to cache
+ handleNetworkFailureWithCacheFallback(inboxType, resultLiveData, "Error: " + resp.code());
+ }
+ });
+ }
+
+ @Override
+ public void onFailure(@NonNull Call call, @NonNull Throwable t) {
+ io.execute(() -> handleNetworkFailureWithCacheFallback(inboxType, resultLiveData, t.getMessage()));
+ }
+ });
+ } else { // offline - use cached mails
+ io.execute(() -> {
+ List cached = getLocalByType(inboxType);
+ if (!cached.isEmpty()) {
+ resultLiveData.postValue(new Result.Success<>(mapLocalToListResponse(cached)));
+ } else {
+ resultLiveData.postValue(new Result.Error<>("No cached data available offline"));
+ }
+ });
+ }
+ }
+
+ /**
+ * Handle network failure by falling back to cache
+ */
+ private void handleNetworkFailureWithCacheFallback(
+ String inboxType,
+ MutableLiveData> resultLiveData,
+ String errorMessage) {
+ List cached = getLocalByType(inboxType);
+ if (!cached.isEmpty()) {
+ // Show cached data but indicate it might be stale
+ resultLiveData.postValue(new Result.Success<>(mapLocalToListResponse(cached)));
+ } else {
+ resultLiveData.postValue(new Result.Error<>(errorMessage));
+ }
+ }
+
+ /**
+ * Update cache with fresh network data and handle conflicts properly
+ */
+ private void updateCacheWithNetworkData(List networkMails, String inboxType, int page) {
+ if (networkMails == null)
+ networkMails = new ArrayList<>();
+ // Clear existing cache for this inbox type to avoid stale data
+ if (page == 1)
+ clearCacheForInboxType(inboxType);
+ // Insert fresh network data
+ upsertServerMails(networkMails);
+ }
+
+ /**
+ * Clear cache entries for specific inbox type to prevent stale data
+ */
+ private void clearCacheForInboxType(String inboxType) {
+ switch ((inboxType == null ? "incoming" : inboxType).toLowerCase()) {
+ case "sent":
+ localData.clearSent();
+ break;
+ case "star":
+ localData.clearStarred();
+ break;
+ case "draft":
+ localData.clearDrafts();
+ break;
+ case "spam":
+ localData.clearSpam();
+ break;
+ case "trash":
+ localData.clearTrash();
+ break;
+ case "incoming":
+ case "all":
+ default:
+ localData.clearIncoming();
+ break;
+ }
+ }
+
+ /**
+ * Fetch mails by label ID.
+ *
+ * @param labelId the label ID to filter by
+ * @param page page number (for pagination)
+ * @param resultLiveData LiveData object to observe result
+ */
+ public void getMailsByLabel(int labelId, int page, MutableLiveData> resultLiveData) {
+
+ if (NetworkUtil.isOnline(context)) {
+ resultLiveData.postValue(new Result.Loading<>());
+
+ mailApi.getMailsByLabel(labelId, page, MAIL_LIMIT).enqueue(new Callback<>() {
+ @Override
+ public void onResponse(@NonNull Call call, @NonNull Response resp) {
+ io.execute(() -> {
+ if (resp.isSuccessful() && resp.body() != null) {
+ // Clear stale cache for this label
+ if (page == 1) localData.clearByLabel(labelId);
+ upsertServerMails(resp.body().getMails());
+
+ // Update with fresh data
+ resultLiveData.postValue(new Result.Success<>(resp.body()));
+ //upsertServerMails(resp.body().getMails());
+ //List freshData = localData.getByLabel(labelId);
+ //resultLiveData.postValue(new Result.Success<>(mapLocalToListResponse(freshData)));
+ } else {
+ // Network failed, fall back to cache
+ List cached = localData.getByLabel(labelId);
+ if (!cached.isEmpty()) {
+ resultLiveData.postValue(new Result.Success<>(mapLocalToListResponse(cached)));
+ } else {
+ resultLiveData.postValue(new Result.Error<>("Error: " + resp.code()));
+ }
+ }
+ });
+ }
+
+ @Override
+ public void onFailure(@NonNull Call call, @NonNull Throwable t) {
+ io.execute(() -> {
+ List cached = localData.getByLabel(labelId);
+ if (!cached.isEmpty()) {
+ resultLiveData.postValue(new Result.Success<>(mapLocalToListResponse(cached)));
+ } else {
+ resultLiveData.postValue(new Result.Error<>(t.getMessage()));
+ }
+ });
+ }
+ });
+ } else {
+ // Offline mode
+ io.execute(() -> {
+ List cached = localData.getByLabel(labelId);
+ if (!cached.isEmpty()) {
+ resultLiveData.postValue(new Result.Success<>(mapLocalToListResponse(cached)));
+ } else {
+ resultLiveData.postValue(new Result.Error<>("No cached data available offline"));
+ }
+ });
+ }
+ }
+
+ /**
+ * Fetch a mail by it's id.
+ *
+ * @param id id of the mail
+ * @param resultLiveData a LiveData object to observe result (mail object)
+ */
+ public void fetchMail(int id, MutableLiveData> resultLiveData) {
+ // show cached if available (instant open offline)
+ io.execute(() -> {
+ MailEntity cached = localData.getById(id);
+ if (cached != null) {
+ localData.touchMails(java.util.Collections.singletonList(id));
+ List single = new ArrayList<>();
+ single.add(cached);
+ List mails = mapEntitiesToNetwork(single);
+ resultLiveData.postValue(new Result.Success<>(mails.get(0)));
+ } else {
+ resultLiveData.postValue(new Result.Loading<>());
+ }
+ });
+ // skip if not connected to internet
+ if (!NetworkUtil.isOnline(context))
+ return;
+ // then try network to refresh
+ mailApi.fetchMail(id).enqueue(new Callback<>() {
+ @Override
+ public void onResponse(@NonNull Call call, @NonNull Response response) {
+ if (!response.isSuccessful() || response.body() == null) return;
+ io.execute(() -> {
+ // keep cache fresh for the opened mail as well
+ List list = new ArrayList<>();
+ list.add(response.body());
+ upsertServerMails(list);
+ MailEntity fresh = localData.getById(id);
+ if (fresh != null) {
+ List single = new ArrayList<>();
+ single.add(fresh);
+ List mails = mapEntitiesToNetwork(single);
+ resultLiveData.postValue(new Result.Success<>(mails.get(0)));
+ }
+ });
+ }
+
+ @Override
+ public void onFailure(@NonNull Call call, @NonNull Throwable t) {
+ // ignore here; cached value already shown (or Loading posted)
+ }
+ });
+ }
+
+ /**
+ * Delete a mail by its ID.
+ *
+ * @param mailId The ID of the mail to delete
+ * @param resultLiveData a LivData object to observe result (void on success)
+ */
+ public void deleteMail(int mailId, MutableLiveData> resultLiveData) {
+ resultLiveData.postValue(new Result.Loading<>());
+
+ mailApi.deleteMail(mailId).enqueue(new Callback() {
+ @Override
+ public void onResponse(@NonNull Call call, @NonNull Response response) {
+ io.execute(() -> {
+ if (response.isSuccessful()) {
+ // Remove from cache immediately
+ localData.deleteMailById(mailId);
+ resultLiveData.postValue(new Result.Success<>(null));
+ } else {
+ resultLiveData.postValue(new Result.Error<>("Error: " + response.code()));
+ }
+ });
+ }
+
+ @Override
+ public void onFailure(@NonNull Call call, @NonNull Throwable t) {
+ resultLiveData.postValue(new Result.Error<>(t.getMessage()));
+ }
+ });
+ }
+
+ /**
+ * toggle a mail's spam flag by calling the backend's POST or DELETE /blacklist/:id endpoint.
+ *
+ * @param request contains the mail ID and user ID
+ * @param resultLiveData result live data to observe success or error
+ */
+ public void toggleSpam(SpamRequest request, MutableLiveData> resultLiveData) {
+ resultLiveData.postValue(new Result.Loading<>());
+ if (!request.getIsPreviouslySpam())
+ mailApi.markAsSpam(request).enqueue(createCallback(resultLiveData));
+ else
+ mailApi.removeFromSpam(request).enqueue(createCallback(resultLiveData));
+ }
+
+ /**
+ * Edits a mail
+ *
+ * @param mailId the ID of the mail to edit
+ * @param req request object containing all data to edit
+ * @param result result live data to observe success or error
+ */
+ public void editMail(int mailId, EditMailRequest req, MutableLiveData> result) {
+ result.postValue(new Result.Loading<>());
+ mailApi.editMail(mailId, req).enqueue(createCallback(result));
+ }
+
+ /**
+ * Search mails using a query from the user.
+ *
+ * @param query string to search for in mails
+ * @param resultLiveData result live data to observe success or error
+ */
+ public void searchMails(String query, MutableLiveData>> resultLiveData) {
+ resultLiveData.postValue(new Result.Loading<>());
+ mailApi.searchMails(query).enqueue(createCallback(resultLiveData));
+ }
+
+ /**
+ * Sends a specific mail to other users
+ *
+ * @param req request object containing mail's data to be sent
+ * @param result result live data to observe success or error
+ */
+ public void sendMail(SendMailRequest req, MutableLiveData> result) {
+ result.postValue(new Result.Loading<>());
+ mailApi.sendMail(req).enqueue(createCallback(result));
+ }
+
+ /**
+ * Updates a specific draft
+ *
+ * @param mailId id of the draft we edited
+ * @param req request object containing all data to edit
+ * @param result result live data to observe success or error
+ */
+ public void updateDraft(int mailId, SendMailRequest req, MutableLiveData> result) {
+ result.postValue(new Result.Loading<>());
+ mailApi.updateDraft(mailId, req).enqueue(createCallback(result));
+ }
+
+ /**
+ * Helper class to centralize callback creation
+ *
+ * @param liveData result live data to show
+ * @return callback of type T
+ */
+ private Callback createCallback(MutableLiveData> liveData) {
+ return new Callback<>() {
+ @Override
+ public void onResponse(@NonNull Call call, @NonNull Response response) {
+ if (response.isSuccessful()) {
+ liveData.postValue(new Result.Success<>(response.body()));
+ } else {
+ liveData.postValue(new Result.Error<>("Error: " + response.code()));
+ }
+ }
+
+ @Override
+ public void onFailure(@NonNull Call call, @NonNull Throwable t) {
+ liveData.postValue(new Result.Error<>(t.getMessage()));
+ }
+ };
+ }
+}
\ No newline at end of file
diff --git a/android_app/app/src/main/java/com/asp/android_app/repository/UserRepository.java b/android_app/app/src/main/java/com/asp/android_app/repository/UserRepository.java
new file mode 100644
index 00000000..20370ad8
--- /dev/null
+++ b/android_app/app/src/main/java/com/asp/android_app/repository/UserRepository.java
@@ -0,0 +1,125 @@
+package com.asp.android_app.repository;
+
+import android.content.Context;
+import android.util.Log;
+
+import androidx.annotation.NonNull;
+import androidx.lifecycle.MutableLiveData;
+
+import com.asp.android_app.api.ApiClient;
+import com.asp.android_app.api.UserApi;
+import com.asp.android_app.model.User;
+import com.asp.android_app.model.request.LoginRequest;
+import com.asp.android_app.model.request.ProfileImageRequest;
+import com.asp.android_app.model.request.RegisterRequest;
+import com.asp.android_app.model.response.AuthResponse;
+import com.asp.android_app.model.response.UserInfo;
+import com.asp.android_app.model.response.UserSearchResult;
+import com.asp.android_app.utils.Result;
+
+import java.util.List;
+
+import retrofit2.Call;
+import retrofit2.Callback;
+import retrofit2.Response;
+
+/**
+ * Repository class for user related operations such as login, registration, and profile fetch.
+ */
+public class UserRepository {
+
+ private final UserApi userApi;
+
+ public UserRepository(Context context) {
+ userApi = ApiClient.getClient(context).create(UserApi.class);
+ }
+
+ public void login(LoginRequest request, MutableLiveData> resultLiveData) {
+ resultLiveData.postValue(new Result.Loading<>());
+ userApi.login(request).enqueue(createCallback(resultLiveData));
+ }
+
+ public void register(User u, MutableLiveData> resultLiveData) {
+ resultLiveData.postValue(new Result.Loading<>());
+ RegisterRequest req = new RegisterRequest(u);
+ userApi.register(req).enqueue(createCallback(resultLiveData));
+ }
+
+ public void validateToken(MutableLiveData> resultLiveData) {
+ resultLiveData.postValue(new Result.Loading<>());
+ userApi.validateToken().enqueue(new Callback<>() {
+ @Override
+ public void onResponse(@NonNull Call call, @NonNull Response resp) {
+ if (resp.isSuccessful() && resp.body() != null) {
+ resultLiveData.postValue(new Result.Success<>(resp.body()));
+ } else if (resp.code() == 401) {
+ // special case: token invalid/expired
+ resultLiveData.postValue(new Result.Error<>("UNAUTHORIZED"));
+ } else {
+ resultLiveData.postValue(new Result.Error<>("Error: " + resp.code()));
+ }
+ }
+
+ @Override
+ public void onFailure(@NonNull Call call, @NonNull Throwable t) {
+ resultLiveData.postValue(new Result.Error<>(t.getMessage()));
+ }
+ });
+ }
+
+ public void fetchUserInfo(int userId, MutableLiveData> resultLiveData) {
+ resultLiveData.postValue(new Result.Loading<>());
+ userApi.fetchUserInfo(userId).enqueue(createCallback(resultLiveData));
+ }
+
+ public void changeProfileImage(int userId, ProfileImageRequest request,
+ MutableLiveData> resultLiveData) {
+ resultLiveData.postValue(new Result.Loading<>());
+ userApi.changeProfileImage(userId, request).enqueue(createCallback(resultLiveData));
+ }
+
+ public void searchUsers(String q, MutableLiveData>> result) {
+ result.postValue(new Result.Loading<>());
+ userApi.searchByEmail(q).enqueue(new Callback<>() {
+ @Override
+ public void onResponse(@NonNull Call> call, @NonNull Response> resp) {
+ if (resp.isSuccessful()) result.postValue(new Result.Success<>(resp.body()));
+ else result.postValue(new Result.Error<>("Search error: " + resp.code()));
+ }
+
+ @Override
+ public void onFailure(@NonNull Call> call, @NonNull Throwable t) {
+ result.postValue(new Result.Error<>(t.getMessage()));
+ }
+ });
+ }
+
+
+ /**
+ * Helper class to centralize callback creation
+ *
+ * @param liveData result live data to show
+ * @return callback of type T
+ */
+ private Callback createCallback(MutableLiveData> liveData) {
+ return new Callback<>() {
+ @Override
+ public void onResponse(@NonNull Call call, @NonNull Response response) {
+ try {
+ Log.i("UserRepo", "" + response.raw());
+ } catch (Exception ignored) {
+ }
+ if (response.isSuccessful()) {
+ liveData.postValue(new Result.Success<>(response.body()));
+ } else {
+ liveData.postValue(new Result.Error<>("Error: " + response.raw()));
+ }
+ }
+
+ @Override
+ public void onFailure(@NonNull Call call, @NonNull Throwable t) {
+ liveData.postValue(new Result.Error<>(t.getMessage()));
+ }
+ };
+ }
+}
\ No newline at end of file
diff --git a/android_app/app/src/main/java/com/asp/android_app/ui/ReadingActivity.java b/android_app/app/src/main/java/com/asp/android_app/ui/ReadingActivity.java
new file mode 100644
index 00000000..1d6fbb6b
--- /dev/null
+++ b/android_app/app/src/main/java/com/asp/android_app/ui/ReadingActivity.java
@@ -0,0 +1,504 @@
+package com.asp.android_app.ui;
+
+import static android.view.View.GONE;
+import static com.asp.android_app.utils.Base64Converter.displayBase64Image;
+import static com.asp.android_app.utils.Base64Converter.getFileIconResource;
+import static com.asp.android_app.utils.Base64Converter.getMimeType;
+import static com.asp.android_app.utils.Base64Converter.saveBase64FileToCache;
+
+
+import android.content.Intent;
+import android.content.res.ColorStateList;
+import android.net.Uri;
+import android.os.Bundle;
+import android.text.Html;
+import android.text.Spanned;
+import android.text.TextUtils;
+import android.text.method.LinkMovementMethod;
+import android.util.Log;
+import android.view.Menu;
+import android.view.MenuItem;
+import android.view.View;
+import android.view.WindowManager;
+import android.widget.Button;
+import android.widget.CheckBox;
+import android.widget.ImageView;
+import android.widget.LinearLayout;
+import android.widget.TextView;
+import android.widget.Toast;
+
+import androidx.activity.result.ActivityResultLauncher;
+import androidx.annotation.NonNull;
+import androidx.appcompat.app.AlertDialog;
+import androidx.appcompat.app.AppCompatActivity;
+import androidx.appcompat.widget.Toolbar;
+import androidx.core.content.ContextCompat;
+import androidx.core.widget.CompoundButtonCompat;
+import androidx.lifecycle.ViewModelProvider;
+
+import com.asp.android_app.R;
+import com.asp.android_app.model.Label;
+import com.asp.android_app.model.Mail;
+import com.asp.android_app.model.request.SpamRequest;
+import com.asp.android_app.model.response.File;
+import com.asp.android_app.model.response.UserInfo;
+import com.asp.android_app.ui.compose_activity.ComposeMailActivity;
+import com.asp.android_app.utils.ComposeNavigation;
+import com.asp.android_app.utils.ComposeParams;
+import com.asp.android_app.utils.DateUtil;
+import com.asp.android_app.utils.MailHtmlUtil;
+import com.asp.android_app.utils.NetworkUtil;
+import com.asp.android_app.utils.Result;
+import com.asp.android_app.viewmodel.LabelViewModel;
+import com.asp.android_app.viewmodel.MailViewModel;
+import com.google.android.material.dialog.MaterialAlertDialogBuilder;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Objects;
+import java.util.Set;
+
+public class ReadingActivity extends AppCompatActivity {
+
+ private CheckBox starCheckbox;
+ private boolean isMailStarred, suppressStarChange = false;
+
+ private boolean recipientsExpanded = false;
+ private String lastEditAction = "";
+ private Mail mail = null;
+ private MailViewModel mailViewModel;
+
+ private ActivityResultLauncher replyForwardLauncher;
+
+ @Override
+ protected void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ setContentView(R.layout.activity_reading);
+
+ // get mail id and fetch the mail from server
+ int mailId = getIntent().getIntExtra("mailId", -1);
+ if (mailId == -1) {
+ Toast.makeText(this, "Error loading mail", Toast.LENGTH_SHORT).show();
+ finish();
+ }
+ // initialize the mails view model
+ mailViewModel = new ViewModelProvider(this).get(MailViewModel.class);
+ initializeModelViewObservers();
+ mailViewModel.fetchMailById(mailId);
+
+ // initialize reply action buttons
+ Button btnReply = findViewById(R.id.btn_reply);
+ btnReply.setOnClickListener(view -> onReplyClick());
+ Button btnForward = findViewById(R.id.btn_forward);
+ btnForward.setOnClickListener(view -> onForwardClick());
+
+ // initialize the star checkbox
+ starCheckbox = findViewById(R.id.starCheckbox);
+ starCheckbox.setOnCheckedChangeListener((buttonView, isChecked) -> {
+ if (!NetworkUtil.isOnline(this)) {
+ Toast.makeText(this, R.string.no_connection, Toast.LENGTH_SHORT).show();
+ return;
+ }
+ // prevent extra calls when setting manually
+ if (suppressStarChange)
+ return;
+ if (mailId != -1) {
+ // set the star checkbox color
+ int color;
+ if (!isChecked)
+ color = ContextCompat.getColor(this, R.color.light_gray);
+ else
+ color = ContextCompat.getColor(this, R.color.star_fill);
+ CompoundButtonCompat.setButtonTintList(starCheckbox, ColorStateList.valueOf(color));
+ // Disable to prevent mass clicking while waiting for backend response
+ starCheckbox.setEnabled(false);
+ lastEditAction = "star";
+ mailViewModel.toggleStar(mailId, isChecked);
+ isMailStarred = isChecked;
+ }
+ });
+
+ // set the action bar
+ Toolbar toolbar = findViewById(R.id.toolbar);
+ setSupportActionBar(toolbar);
+ Objects.requireNonNull(getSupportActionBar()).setDisplayShowTitleEnabled(false);
+ toolbar.setNavigationOnClickListener(v -> onBackPressed());
+
+ replyForwardLauncher = ComposeNavigation.register(this, new ComposeNavigation.ResultListener() {
+ @Override
+ public void onSent() {
+ setResult(RESULT_OK, new Intent().putExtra("refresh", true));
+ finish();
+ }
+
+ @Override
+ public void onSaved() {
+ setResult(RESULT_OK, new Intent().putExtra("refresh", true));
+ finish();
+ }
+ });
+ }
+
+ @Override
+ public boolean onCreateOptionsMenu(Menu menu) {
+ getMenuInflater().inflate(R.menu.reading_menu, menu);
+ MenuItem deleteRestoreItem = menu.findItem(R.id.action_trash);
+ MenuItem spamToggleItem = menu.findItem(R.id.action_spam);
+ MenuItem deleteForeverItem = menu.findItem(R.id.action_delete_forever);
+
+ // depending on the inbox, change the icons and titles to undo actions like deleting
+ String inboxType = getIntent().getStringExtra("inboxType"); // e.g. "trash", "spam"
+ if ("trash".equals(inboxType)) {
+ deleteRestoreItem.setTitle(R.string.restore_mail);
+ deleteRestoreItem.setIcon(R.drawable.ic_restore);
+ deleteForeverItem.setVisible(true);
+ } else {
+ deleteRestoreItem.setTitle(R.string.delete_mail);
+ deleteRestoreItem.setIcon(R.drawable.ic_delete);
+ deleteForeverItem.setVisible(false);
+ }
+ if ("spam".equals(inboxType)) {
+ spamToggleItem.setTitle(R.string.unspam_mail);
+ spamToggleItem.setIcon(R.drawable.ic_checkmark);
+ } else {
+ spamToggleItem.setTitle(R.string.spam_mails);
+ spamToggleItem.setIcon(R.drawable.ic_report);
+ }
+ return true;
+ }
+
+ @Override
+ public boolean onOptionsItemSelected(@NonNull MenuItem item) {
+ int id = item.getItemId();
+ // no connection - disable features
+ if (!NetworkUtil.isOnline(this)) {
+ Toast.makeText(this, R.string.no_connection, Toast.LENGTH_SHORT).show();
+ return true;
+ }
+
+ if (id == R.id.action_trash) {
+ if (mail.isTrashed()) {
+ lastEditAction = "restore";
+ mailViewModel.restoreMail(mail.getId());
+ } else {
+ lastEditAction = "delete";
+ mailViewModel.deleteMail(mail.getId());
+ }
+ return true;
+ }
+ if (id == R.id.action_spam) {
+ lastEditAction = "spam";
+ mailViewModel.toggleSpam(new SpamRequest(mail.getId(), mail.isSpam()));
+ return true;
+ }
+ if (id == R.id.action_delete_forever) {
+ lastEditAction = "delete";
+ mailViewModel.deleteMail(mail.getId());
+ return true;
+ }
+ if (id == R.id.action_label) {
+ lastEditAction = "label";
+ showLabelPickerDialog();
+ return true;
+ }
+
+ return super.onOptionsItemSelected(item);
+ }
+
+ /**
+ * Observer for changes of the mail object data, when fetching successfully shows the mail's
+ * contents and the sender info.
+ */
+ private void initializeModelViewObservers() {
+ // mail update
+ mailViewModel.getMailLiveData().observe(this,
+ result -> {
+ if (result instanceof Result.Success) {
+ mail = ((Result.Success) result).getData();
+ showSenderAndRecipientsInfo(mail);
+ showMailContent(mail);
+ isMailStarred = mail.isStarred();
+ suppressStarChange = true;
+ starCheckbox.setChecked(isMailStarred);
+ suppressStarChange = false;
+ } else if (result instanceof Result.Error) {
+ Log.i("ERROR READING", ((Result.Error) result).getMessage());
+ }
+ });
+
+ // mail edit
+ mailViewModel.getEditMailStatus().observe(this, result -> {
+ if (result instanceof Result.Error) {
+ Toast.makeText(this, R.string.unexpected_error, Toast.LENGTH_SHORT).show();
+ Log.e("READING", ((Result.Error) result).getMessage());
+ }
+ Intent resultIntent = new Intent();
+ resultIntent.putExtra("refresh", true);
+ resultIntent.putExtra("inboxType", getIntent().getStringExtra("inboxType"));
+
+ switch (lastEditAction) {
+ case "star":
+ starCheckbox.setEnabled(true);
+ lastEditAction = "";
+ break;
+ case "delete":
+ case "restore":
+ case "spam":
+ case "label":
+ setResult(RESULT_OK, resultIntent);
+ lastEditAction = "";
+ finish();
+ break;
+ }
+ });
+ }
+
+ /**
+ * Sets the activity's fields to show the sender's info and other recipients
+ *
+ * @param mail mail object
+ */
+ private void showSenderAndRecipientsInfo(Mail mail) {
+ // show sender's info
+ ImageView senderAvatar = findViewById(R.id.sender_avatar);
+ TextView toggleRecipients = findViewById(R.id.tv_toggle_recipients);
+ TextView allRecipients = findViewById(R.id.tv_all_recipients);
+ TextView senderName = findViewById(R.id.sender_name);
+ TextView senderEmail = findViewById(R.id.sender_email);
+ senderName.setText(mail.getSender().getFullName());
+ senderEmail.setText(mail.getSender().getMail());
+ displayBase64Image(mail.getSender().getImageUrl(), senderAvatar);
+
+ // show recipients info, toggle between showing a few and all recipients
+ List recipients = mail.getSentTo();
+ if (recipients == null || recipients.isEmpty()) {
+ toggleRecipients.setVisibility(View.GONE);
+ allRecipients.setVisibility(View.GONE);
+ return;
+ }
+
+ List recipientMails = new ArrayList<>();
+ for (UserInfo recipient : recipients)
+ recipientMails.add(recipient.getMail());
+
+ // Join with comma and zero width space to allow safe line wrapping
+ String allMails = TextUtils.join(",\u200B ", recipientMails);
+ // Show in full list view
+ allRecipients.setText(allMails);
+ // Show abbreviated preview
+ int count = Math.min(2, recipientMails.size());
+ toggleRecipients.setVisibility(recipientMails.size() > count ? View.VISIBLE : View.GONE);
+
+ // observe clicks on the hide/show text view
+ toggleRecipients.setOnClickListener(v -> {
+ recipientsExpanded = !recipientsExpanded;
+ if (recipientsExpanded) {
+ allRecipients.setVisibility(View.VISIBLE);
+ toggleRecipients.setText(R.string.hide_recipients);
+ return;
+ }
+ allRecipients.setVisibility(View.GONE);
+ toggleRecipients.setText(R.string.more_recipients);
+ });
+ }
+
+ /**
+ * Sets the activity's fields to show the mail's contents (subject, body, time etc.)
+ *
+ * @param mail mail object
+ */
+ private void showMailContent(Mail mail) {
+ // mail contents
+ TextView mailDate = findViewById(R.id.mail_date);
+ TextView mailSubject = findViewById(R.id.mail_subject);
+ TextView mailBody = findViewById(R.id.mail_body);
+ LinearLayout attachmentsContainer = findViewById(R.id.attachments_container);
+
+ if (mail.getSentAt() != null && !mail.getSentAt().isBlank()) {
+ String formattedDate = DateUtil.getFormattedDate(mail.getSentAt());
+ String formattedTime = DateUtil.getFormattedHour(mail.getSentAt());
+ String combined = formattedDate + "\n" + formattedTime;
+ mailDate.setText(combined);
+ }
+ mailSubject.setText(mail.getSubject());
+
+ // display formatted mail content
+ if (mail.getBody() != null && !mail.getBody().isEmpty()) {
+ Spanned formatted;
+ formatted = Html.fromHtml(mail.getBody(), Html.FROM_HTML_MODE_LEGACY);
+ mailBody.setText(formatted);
+ // Clickable links
+ mailBody.setMovementMethod(LinkMovementMethod.getInstance());
+ }
+
+ // if there are no attachments - hide the header
+ List files = mail.getAttachments();
+ if (files == null || files.isEmpty()) {
+ attachmentsContainer.setVisibility(GONE);
+ } else {
+ attachmentsContainer.setVisibility(View.VISIBLE);
+ showAttachments(mail.getAttachments());
+ }
+ }
+
+ /**
+ * Shows mail attachments as a horizontal list
+ *
+ * @param files list of attachments in mail
+ */
+ private void showAttachments(List files) {
+ LinearLayout attachmentsLayout = findViewById(R.id.attachmentsLayout);
+ attachmentsLayout.removeAllViews(); // Clear any previous ones
+ for (File file : files) {
+ View attachmentView = getLayoutInflater().inflate(R.layout.attachment_item, attachmentsLayout, false);
+ TextView fileName = attachmentView.findViewById(R.id.attachment_name);
+ ImageView thumbnail = attachmentView.findViewById(R.id.attachment_thumbnail);
+ // set resources
+ fileName.setText(file.getName());
+ int iconRes = getFileIconResource(file.getName());
+ thumbnail.setImageResource(iconRes);
+ // cache and preview on click
+ attachmentView.setOnClickListener(v -> {
+ openFile(file.getName(), file.getData());
+ });
+ attachmentsLayout.addView(attachmentView);
+ }
+ }
+
+ /**
+ * Opens an attachment file
+ *
+ * @param fileName name of the file
+ * @param base64Data string in base64 format of the file
+ */
+ private void openFile(String fileName, String base64Data) {
+ // attempt to save the file to cache
+ Uri fileUri = saveBase64FileToCache(this, fileName, base64Data);
+ if (fileUri == null) {
+ Toast.makeText(this, "Failed to cache file", Toast.LENGTH_SHORT).show();
+ return;
+ }
+
+ String mimeType = getMimeType(base64Data);
+ if (mimeType == null) mimeType = "*/*";
+
+ // open the file, if several options exist let the user pick
+ Intent intent = new Intent(Intent.ACTION_VIEW);
+ intent.setDataAndType(fileUri, mimeType);
+ intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);
+
+ try {
+ startActivity(Intent.createChooser(intent, "Open with"));
+ } catch (Exception e) {
+ Toast.makeText(this, "No app found to open this file type", Toast.LENGTH_SHORT).show();
+ }
+ }
+
+ /**
+ * Shows a dialog to un/mark the current mail with labels
+ */
+ private void showLabelPickerDialog() {
+ LabelViewModel labelVM = new ViewModelProvider(this).get(LabelViewModel.class);
+ labelVM.fetchAllLabels();
+ labelVM.getAllLabels().observe(this, allLabels -> {
+ if (mail == null) {
+ Toast.makeText(this, R.string.err_mails_load, Toast.LENGTH_SHORT).show();
+ return;
+ }
+ if (allLabels == null || allLabels.isEmpty()) {
+ Toast.makeText(this, R.string.labels_not_found, Toast.LENGTH_SHORT).show();
+ return;
+ }
+
+ String[] labelNames = allLabels.stream().map(Label::getName).toArray(String[]::new);
+ boolean[] checked = new boolean[allLabels.size()];
+
+ // mark as checked the labels already set for the mail
+ Set common = new HashSet<>(mail.getLabels());
+ for (int i = 0; i < allLabels.size(); i++)
+ if (common.contains(allLabels.get(i)))
+ checked[i] = true;
+
+ AlertDialog alertDialog = new MaterialAlertDialogBuilder(this)
+ .setTitle(R.string.choose_label)
+ .setMultiChoiceItems(labelNames, checked, (dialogInterface, indexSelected, isChecked) -> {
+ checked[indexSelected] = isChecked;
+ })
+ .setPositiveButton(R.string.apply, null)
+ .setNegativeButton(R.string.cancel, null)
+ .create();
+ // Prevent outside touch from dismissing or passing through
+ alertDialog.setCanceledOnTouchOutside(true);
+ alertDialog.setCancelable(true);
+ // Block swipe gestures from passing through
+ alertDialog.getWindow().setFlags(
+ WindowManager.LayoutParams.FLAG_NOT_TOUCH_MODAL,
+ WindowManager.LayoutParams.FLAG_NOT_TOUCH_MODAL
+ );
+
+ alertDialog.show();
+ // set click listeners
+ alertDialog.getButton(AlertDialog.BUTTON_NEGATIVE).setOnClickListener(v -> alertDialog.dismiss());
+ alertDialog.getButton(AlertDialog.BUTTON_POSITIVE).setOnClickListener(v -> {
+ List selectedLabels = new ArrayList<>();
+ for (int i = 0; i < checked.length; i++)
+ if (checked[i])
+ selectedLabels.add(allLabels.get(i));
+ // update locally
+ mail.setLabels(selectedLabels);
+ mailViewModel.updateMailLabels(Collections.singletonList(mail), selectedLabels);
+ alertDialog.dismiss();
+ });
+ });
+ }
+
+ /**
+ * Reply to the sender only
+ */
+ private void onReplyClick() {
+ if (mail == null) {
+ Toast.makeText(this, R.string.err_mails_load, Toast.LENGTH_SHORT).show();
+ return;
+ }
+ if (!NetworkUtil.isOnline(this)) {
+ Toast.makeText(this, R.string.no_connection, Toast.LENGTH_SHORT).show();
+ return;
+ }
+
+ ComposeParams params = new ComposeParams();
+ if (mail.getSender() != null)
+ params.recipients.add(mail.getSender());
+ params.subject = MailHtmlUtil.subjectForReply(mail.getSubject());
+ params.quotedHtml = MailHtmlUtil.buildReplyQuotedHtml(mail);
+ params.isReply = true;
+
+ Intent i = ComposeMailActivity.newIntent(this, params);
+ replyForwardLauncher.launch(i);
+ }
+
+ /**
+ * Forward the mail (no recipients pre filled), will keep the original attachments
+ */
+ private void onForwardClick() {
+ if (mail == null) {
+ Toast.makeText(this, R.string.err_mails_load, Toast.LENGTH_SHORT).show();
+ return;
+ }
+ if (!NetworkUtil.isOnline(this)) {
+ Toast.makeText(this, R.string.no_connection, Toast.LENGTH_SHORT).show();
+ return;
+ }
+
+ ComposeParams params = new ComposeParams();
+ params.subject = MailHtmlUtil.subjectForForward(mail.getSubject());
+ params.quotedHtml = MailHtmlUtil.buildForwardQuotedHtml(mail);
+ if (mail.getAttachments() != null)
+ params.files.addAll(mail.getAttachments());
+ params.isForward = true;
+
+ Intent i = ComposeMailActivity.newIntent(this, params);
+ replyForwardLauncher.launch(i);
+ }
+}
\ No newline at end of file
diff --git a/android_app/app/src/main/java/com/asp/android_app/ui/auth_activity/AuthActivity.java b/android_app/app/src/main/java/com/asp/android_app/ui/auth_activity/AuthActivity.java
new file mode 100644
index 00000000..ba8a3d02
--- /dev/null
+++ b/android_app/app/src/main/java/com/asp/android_app/ui/auth_activity/AuthActivity.java
@@ -0,0 +1,98 @@
+package com.asp.android_app.ui.auth_activity;
+
+import android.content.Intent;
+import android.os.Bundle;
+
+import androidx.appcompat.app.AppCompatActivity;
+import androidx.lifecycle.ViewModelProvider;
+
+import com.asp.android_app.R;
+import com.asp.android_app.model.response.AuthResponse;
+import com.asp.android_app.model.response.UserInfo;
+import com.asp.android_app.ui.inbox_activity.InboxActivity;
+import com.asp.android_app.utils.NetworkUtil;
+import com.asp.android_app.utils.Result;
+import com.asp.android_app.utils.TokenManager;
+import com.asp.android_app.viewmodel.UserViewModel;
+
+public class AuthActivity extends AppCompatActivity {
+
+
+ @Override
+ protected void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ setContentView(R.layout.activity_auth);
+
+ UserViewModel userViewModel = new ViewModelProvider(this).get(UserViewModel.class);
+ TokenManager tokenManager = TokenManager.getInstance(this);
+ String token = tokenManager.getToken();
+
+ // set observer
+ userViewModel.getAuthResult().observe(this, result -> {
+ if (result instanceof Result.Success) {
+ // cache user for future offline boots
+ AuthResponse auth = ((Result.Success) result).getData();
+ if (auth != null && auth.getUser() != null) tokenManager.saveUser(auth.getUser());
+ goToInbox(auth != null ? auth.getUser() : tokenManager.getUser(), false);
+ } else if (result instanceof Result.Error) {
+ String msg = ((Result.Error>) result).getMessage();
+ // If we are ONLINE and got explicit UNAUTHORIZED - bounce to login.
+ if (NetworkUtil.isOnline(this) && "UNAUTHORIZED".equals(msg)) {
+ tokenManager.clearToken();
+ tokenManager.clearUser();
+ showLoginFragment();
+ } else {
+ UserInfo cached = tokenManager.getUser();
+ if (cached != null) {
+ goToInbox(cached, true);
+ } else {
+ showLoginFragment();
+ }
+ }
+ }
+ });
+
+ // Entry decision - make sure the token exists, and still fresh
+ if (token == null || token.isBlank() || !tokenManager.isFreshToken()) {
+ showLoginFragment();
+ return;
+ }
+
+ // Token exists:
+ if (NetworkUtil.isOnline(this)) {
+ // Online -> do real validation
+ userViewModel.validateToken();
+ } else {
+ // Offline - enter Inbox immediately using cached user (if we have one)
+ UserInfo cached = tokenManager.getUser();
+ // If cached == null, we can still enter Inbox (it only reads from Room ),
+ // or show login—your call. I prefer entering Inbox so offline cache is useful.
+ goToInbox(cached, true);
+ // Optional: We could register a network callback here to revalidate when online.
+ // Keeping it simple; Inbox can also trigger revalidation.
+ }
+
+ }
+
+ /**
+ * Shows the login/ signup fragments
+ */
+ private void showLoginFragment() {
+ getSupportFragmentManager()
+ .beginTransaction()
+ .replace(R.id.fragment_container, new LoginFragment())
+ .commit();
+ }
+
+ /**
+ * Launch Inbox with an optional user and an offline flag.
+ * If user is null, Inbox should still work (local cache + "unknown user" UI).
+ */
+ private void goToInbox(UserInfo user, boolean offline) {
+ Intent intent = new Intent(this, InboxActivity.class);
+ if (user != null) intent.putExtra("user", user);
+ intent.putExtra("offline_mode", offline);
+ startActivity(intent);
+ finish();
+ }
+}
\ No newline at end of file
diff --git a/android_app/app/src/main/java/com/asp/android_app/ui/auth_activity/LoginFragment.java b/android_app/app/src/main/java/com/asp/android_app/ui/auth_activity/LoginFragment.java
new file mode 100644
index 00000000..47ce2228
--- /dev/null
+++ b/android_app/app/src/main/java/com/asp/android_app/ui/auth_activity/LoginFragment.java
@@ -0,0 +1,202 @@
+package com.asp.android_app.ui.auth_activity;
+
+import static com.asp.android_app.utils.InputValidation.isEmailValid;
+import static com.asp.android_app.utils.InputValidation.isPasswordValid;
+
+import android.content.Intent;
+import android.content.res.Resources;
+import android.os.Bundle;
+import android.os.Handler;
+import android.os.Looper;
+import android.util.Log;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.Button;
+import android.widget.Toast;
+
+import androidx.fragment.app.Fragment;
+import androidx.lifecycle.ViewModelProvider;
+
+import com.asp.android_app.R;
+import com.asp.android_app.model.request.LoginRequest;
+import com.asp.android_app.model.response.AuthResponse;
+import com.asp.android_app.repository.UserRepository;
+import com.asp.android_app.ui.inbox_activity.InboxActivity;
+import com.asp.android_app.utils.Result;
+import com.asp.android_app.utils.TokenManager;
+import com.asp.android_app.viewmodel.UserViewModel;
+import com.google.android.material.textfield.TextInputEditText;
+import com.google.android.material.textfield.TextInputLayout;
+
+public class LoginFragment extends Fragment {
+
+ public LoginFragment() {
+ }
+
+ UserViewModel userViewModel;
+
+ @Override
+ public View onCreateView(LayoutInflater inflater, ViewGroup container,
+ Bundle savedInstanceState) {
+ View view = inflater.inflate(R.layout.login_fragment, container, false);
+
+ // Initialize the view model class
+ userViewModel = new ViewModelProvider(this).get(UserViewModel.class);
+
+ // Initialize views
+ Button btnSwitch = view.findViewById(R.id.btnSignup);
+ Button btnLogin = view.findViewById(R.id.btnLogin);
+ TextInputLayout emailLayout = view.findViewById(R.id.emailLayout);
+ TextInputEditText etAddress = view.findViewById(R.id.etAddress);
+ TextInputLayout passwordLayout = view.findViewById(R.id.passwordLayout);
+ TextInputEditText etPassword = view.findViewById(R.id.etPassword);
+
+ // Initialize the repository class
+ UserRepository userRepository = new UserRepository(requireContext());
+
+ // Handle signup button clicks - change to the signup fragment
+ btnSwitch.setOnClickListener(v ->
+ requireActivity().getSupportFragmentManager()
+ .beginTransaction()
+ .replace(R.id.fragment_container, new SignupFragment())
+ .addToBackStack(null)
+ .commit());
+
+ // Handle login button clicks - navigate to the inbox activity
+ btnLogin.setOnClickListener(view1 -> {
+ onLoginCLick(emailLayout, etAddress, passwordLayout, etPassword);
+ });
+
+ return view;
+ }
+
+ /**
+ * Checks if input fields are valid
+ *
+ * @param emailLayout layout containing the email address input
+ * @param email mail address input
+ * @param passwordLayout layout containing the password input
+ * @param password password input
+ * @return true if all fields are valid, otherwise false
+ */
+ private boolean isInputValid(
+ TextInputLayout emailLayout,
+ String email,
+ TextInputLayout passwordLayout,
+ String password) {
+
+ Resources res = getResources();
+
+ // check email input validity
+ boolean isEmailValid = isEmailValid(email);
+ if (!isEmailValid) {
+ emailLayout.setError(res.getString(R.string.invalid_email));
+ } else {
+ emailLayout.setError(null);
+ }
+ // check password input validity
+ boolean isPasswordValid = isPasswordValid(password);
+ if (!isPasswordValid) {
+ passwordLayout.setError(res.getString(R.string.empty_password));
+ } else {
+ passwordLayout.setError(null);
+ }
+
+ // if all fields are valid return true
+ return isEmailValid && isPasswordValid;
+ }
+
+ /**
+ * Handles login button click - checks validity and login
+ *
+ * @param emailLayout layout containing the email address input
+ * @param etEmail edit text of the email
+ * @param passwordLayout layout containing the password input
+ * @param etPassword edit text of the password
+ */
+ private void onLoginCLick(
+ TextInputLayout emailLayout,
+ TextInputEditText etEmail,
+ TextInputLayout passwordLayout,
+ TextInputEditText etPassword) {
+ String mail = etEmail.getText().toString().trim();
+ String password = etPassword.getText().toString().trim();
+
+ if (!isInputValid(emailLayout, mail, passwordLayout, password))
+ return;
+
+ LoginRequest request = new LoginRequest(mail, password);
+ userViewModel.login(request);
+
+ // wait for results
+ userViewModel.getAuthResult().observe(getViewLifecycleOwner(), result -> {
+ if (result instanceof Result.Success) {
+ AuthResponse auth = ((Result.Success) result).getData();
+ handleLoginSuccess(auth);
+ } else if (result instanceof Result.Error) {
+ String msg = ((Result.Error>) result).getMessage();
+ handleLoginError(msg, passwordLayout, etPassword);
+ }
+ });
+ }
+
+ /**
+ * Handles the case of successful login - saves the JWT token and moves to inbox
+ *
+ * @param auth authorization call response instance
+ */
+ private void handleLoginSuccess(AuthResponse auth) {
+ // save the token
+ TokenManager tokenManager = TokenManager.getInstance(requireContext());
+ tokenManager.saveToken(auth.getToken());
+ tokenManager.saveUser(auth.getUser());
+
+ // added small delay to let the data be saved locally
+ new Handler(Looper.getMainLooper()).postDelayed(() -> {
+ // navigate to the inbox screen
+ Intent intent = new Intent(getContext(), InboxActivity.class);
+ intent.putExtra("user", auth.getUser());
+ startActivity(intent);
+ requireActivity().finish();
+ try {
+ Log.i("loginFrag", "welcome " + auth.getUser().getFullName());
+ Toast.makeText(getContext(), "Welcome " + auth.getUser().getFullName(), Toast.LENGTH_SHORT).show();
+ } catch (Exception e) {
+ Log.i("loginFrag", "user null");
+ }
+ }, 100);
+ }
+
+ /**
+ * Handles errors that occur during login
+ *
+ * @param msg message from the server
+ * @param layout layout containing the password edit text
+ * @param et edit text of the password
+ */
+ private void handleLoginError(String msg, TextInputLayout layout, TextInputEditText et) {
+ Log.i("login error:", msg); // log
+ // if it's wrong mail and password - clear the password and inform
+ if (msg.contains("401") || msg.contains("wrong")) {
+ layout.setError(getResources().getString(R.string.wrong_input));
+ et.setText("");
+ return;
+ }
+ // check if the server is up
+ if (msg.contains("Failed to connect")) {
+ Toast.makeText(
+ requireContext(),
+ getResources().getString(R.string.server_off),
+ Toast.LENGTH_SHORT
+ ).show();
+ return;
+ }
+
+ Toast.makeText(
+ requireContext(),
+ getResources().getString(R.string.unexpected_error),
+ Toast.LENGTH_SHORT
+ ).show();
+ }
+}
\ No newline at end of file
diff --git a/android_app/app/src/main/java/com/asp/android_app/ui/auth_activity/SignupFragment.java b/android_app/app/src/main/java/com/asp/android_app/ui/auth_activity/SignupFragment.java
new file mode 100644
index 00000000..2a8178f7
--- /dev/null
+++ b/android_app/app/src/main/java/com/asp/android_app/ui/auth_activity/SignupFragment.java
@@ -0,0 +1,241 @@
+package com.asp.android_app.ui.auth_activity;
+
+import static com.asp.android_app.utils.Base64Converter.displayBase64Image;
+import static com.asp.android_app.utils.InputValidation.calculateAge;
+import static com.asp.android_app.utils.InputValidation.isEmailValid;
+import static com.asp.android_app.utils.InputValidation.isFullNameValid;
+import static com.asp.android_app.utils.InputValidation.isPasswordValid;
+
+import android.app.DatePickerDialog;
+import android.content.Intent;
+import android.content.res.Resources;
+import android.os.Bundle;
+import android.util.Log;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.ImageView;
+import android.widget.Toast;
+
+import androidx.activity.result.ActivityResultLauncher;
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.fragment.app.Fragment;
+import androidx.lifecycle.ViewModelProvider;
+
+import com.asp.android_app.R;
+import com.asp.android_app.model.User;
+import com.asp.android_app.model.response.AuthResponse;
+import com.asp.android_app.ui.inbox_activity.InboxActivity;
+import com.asp.android_app.utils.ImagePicker;
+import com.asp.android_app.utils.Result;
+import com.asp.android_app.utils.TokenManager;
+import com.asp.android_app.viewmodel.UserViewModel;
+import com.google.android.material.button.MaterialButton;
+import com.google.android.material.textfield.TextInputEditText;
+import com.google.android.material.textfield.TextInputLayout;
+
+import java.util.Calendar;
+
+public class SignupFragment extends Fragment {
+
+ private ActivityResultLauncher imagePickerLauncher;
+
+ UserViewModel userViewModel;
+
+ TextInputLayout nameLayout, emailLayout, passwordLayout, birthDateLayout;
+ TextInputEditText etName, etEmail, etPassword, etBirthDate;
+ ImageView profileImage;
+
+ @Nullable
+ @Override
+ public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container,
+ @Nullable Bundle savedInstanceState) {
+ View view = inflater.inflate(R.layout.signup_fragment, container, false);
+
+ // Initialize the view model class
+ userViewModel = new ViewModelProvider(this).get(UserViewModel.class);
+
+ // Initialize the views
+ nameLayout = view.findViewById(R.id.nameLayout);
+ etName = view.findViewById(R.id.etName);
+ emailLayout = view.findViewById(R.id.emailLayout);
+ etEmail = view.findViewById(R.id.etEmail);
+ passwordLayout = view.findViewById(R.id.passwordLayout);
+ etPassword = view.findViewById(R.id.etPassword);
+ birthDateLayout = view.findViewById(R.id.birthDateLayout);
+ etBirthDate = view.findViewById(R.id.etBirthDate);
+ profileImage = view.findViewById(R.id.profileImage);
+ MaterialButton btnSignup = view.findViewById(R.id.btnSignup);
+
+ // clicking the date of birth will let you pick a date
+ etBirthDate.setOnClickListener(v -> showDatePicker(etBirthDate));
+ // clicking the avatar will let you pick a new profile picture
+ profileImage.setOnClickListener(v -> imagePickerLauncher.launch("image/*"));
+ // clicking signup will ensure the input is valid and create a new user
+ btnSignup.setOnClickListener(view1 -> handleSignup());
+
+ // create the external activity of picking an image
+ imagePickerLauncher = ImagePicker.registerImagePicker(this, requireContext(),
+ new ImagePicker.ImagePickerCallback() {
+ @Override
+ public void onImagePicked(String base64Image) {
+ displayBase64Image(base64Image, profileImage);
+ // store base64 in a tag or field for use in registering
+ profileImage.setTag(base64Image);
+ }
+
+ @Override
+ public void onError(String message) {
+ Toast.makeText(getContext(), message, Toast.LENGTH_SHORT).show();
+ }
+ });
+
+
+ return view;
+ }
+
+ /**
+ * Shows a dialog to pick the birthday
+ *
+ * @param etBirthDate edit text of the birthday, will edit it with the date picked
+ */
+ private void showDatePicker(TextInputEditText etBirthDate) {
+ Calendar calendar = Calendar.getInstance();
+ DatePickerDialog dialog = new DatePickerDialog(requireContext(),
+ (view, year, month, dayOfMonth) -> {
+ final String FORMAT = "%04d/%02d/%02d";
+ String formatted = String.format(FORMAT, year, month + 1, dayOfMonth);
+ etBirthDate.setText(formatted);
+ },
+ calendar.get(Calendar.YEAR),
+ calendar.get(Calendar.MONTH),
+ calendar.get(Calendar.DAY_OF_MONTH));
+ dialog.show();
+ }
+
+ /**
+ * Checks if input fields are valid
+ *
+ * @return true if all fields are valid, otherwise false
+ */
+ private boolean isInputValid() {
+ String name = etName.getText().toString().trim();
+ String email = etEmail.getText().toString().trim();
+ String password = etPassword.getText().toString().trim();
+ String birthDate = etBirthDate.getText().toString().trim();
+ Resources res = getResources();
+
+ // check name input validity
+ boolean isNameValid = isFullNameValid(name);
+ if (!isNameValid) {
+ nameLayout.setError(res.getString(R.string.invalid_name));
+ } else {
+ nameLayout.setError(null);
+ }
+ // check email input validity
+ boolean isEmailValid = isEmailValid(email);
+ if (!isEmailValid) {
+ emailLayout.setError(res.getString(R.string.invalid_email));
+ } else {
+ emailLayout.setError(null);
+ }
+ // check password input validity
+ boolean isPasswordValid = isPasswordValid(password);
+ if (!isPasswordValid) {
+ passwordLayout.setError(res.getString(R.string.empty_password));
+ } else {
+ passwordLayout.setError(null);
+ }
+ // check birth date input validity, showing custom messages if age is invalid
+ int age = calculateAge(birthDate);
+ boolean isAgeValid;
+ if (age < 0) {
+ birthDateLayout.setError(getResources().getString(R.string.empty_birth_date));
+ isAgeValid = false;
+ } else if (age < 3) {
+ birthDateLayout.setError(res.getString(R.string.invalid_birth_date, age));
+ isAgeValid = false;
+ } else if (age > 100) {
+ birthDateLayout.setError(res.getString(R.string.invalid_birth_date, age));
+ isAgeValid = false;
+ } else {
+ birthDateLayout.setError(null);
+ isAgeValid = true;
+ }
+
+ // if all fields are valid return true
+ return isNameValid && isEmailValid && isPasswordValid && isAgeValid;
+ }
+
+
+ /**
+ * Handles the signup of a user, checks the user's input and sends the input to the server
+ */
+ private void handleSignup() {
+ // get the input
+ String name = etName.getText().toString().trim();
+ String email = etEmail.getText().toString().trim();
+ String password = etPassword.getText().toString().trim();
+ String birthDate = etBirthDate.getText().toString().trim();
+ String imageBase64 = "";
+
+ Object tag = profileImage.getTag();
+ if (tag instanceof String)
+ imageBase64 = (String) tag;
+
+ // check input validity - if info isn't valid don't continue
+ if (!isInputValid())
+ return;
+
+ // register
+ User newUser = new User(name, email, password, birthDate, imageBase64);
+ userViewModel.register(newUser);
+ // wait for results
+ userViewModel.getAuthResult().observe(getViewLifecycleOwner(), result -> {
+ if (result instanceof Result.Success) {
+ AuthResponse auth = ((Result.Success) result).getData();
+ handleSignupSuccess(auth);
+ } else if (result instanceof Result.Error) {
+ String msg = ((Result.Error>) result).toString();
+ handleSignupErrors(msg, emailLayout);
+ }
+ });
+ }
+
+ /**
+ * Handles the case of successful signup - saves the JWT token and moves to inbox
+ *
+ * @param auth authorization call response instance
+ */
+ private void handleSignupSuccess(AuthResponse auth) {
+ // save the token
+ TokenManager tokenManager = TokenManager.getInstance(requireContext());
+ tokenManager.saveToken(auth.getToken());
+ // navigate to the inbox screen
+ Toast.makeText(getContext(), "Welcome " + auth.getUser().getFullName(), Toast.LENGTH_SHORT).show();
+ startActivity(new Intent(getContext(), InboxActivity.class));
+ requireActivity().finish();
+ }
+
+ /**
+ * Handles errors that occur during login
+ *
+ * @param msg message from the server
+ * @param emailLayout layout containing the edit text of the mail address
+ */
+ private void handleSignupErrors(String msg, TextInputLayout emailLayout) {
+ Log.i("signup error:", msg);
+ // error of already existing mail address
+ if (msg.contains("400") && msg.contains("exists")) {
+ emailLayout.setError("Email address taken");
+ return;
+ }
+ Toast.makeText(
+ getContext(),
+ getResources().getString(R.string.unexpected_error) + msg,
+ Toast.LENGTH_LONG
+ ).show();
+ }
+
+}
diff --git a/android_app/app/src/main/java/com/asp/android_app/ui/compose_activity/ComposeFragment.java b/android_app/app/src/main/java/com/asp/android_app/ui/compose_activity/ComposeFragment.java
new file mode 100644
index 00000000..7e0cccd1
--- /dev/null
+++ b/android_app/app/src/main/java/com/asp/android_app/ui/compose_activity/ComposeFragment.java
@@ -0,0 +1,481 @@
+package com.asp.android_app.ui.compose_activity;
+
+import static android.app.Activity.RESULT_OK;
+
+import android.content.ClipData;
+import android.content.Intent;
+import android.database.Cursor;
+import android.net.Uri;
+import android.os.Bundle;
+import android.os.Handler;
+import android.os.Looper;
+import android.provider.OpenableColumns;
+import android.text.Editable;
+import android.text.TextWatcher;
+import android.view.View;
+
+import androidx.activity.result.ActivityResultLauncher;
+import androidx.activity.result.contract.ActivityResultContracts;
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.fragment.app.Fragment;
+import androidx.lifecycle.ViewModelProvider;
+
+import android.view.LayoutInflater;
+import android.view.ViewGroup;
+import android.widget.Button;
+import android.widget.Toast;
+
+import com.asp.android_app.R;
+import com.asp.android_app.model.Mail;
+import com.asp.android_app.model.request.SendMailRequest;
+import com.asp.android_app.model.response.File;
+import com.asp.android_app.model.response.UserInfo;
+import com.asp.android_app.model.response.UserSearchResult;
+import com.asp.android_app.utils.Base64Converter;
+import com.asp.android_app.utils.ComposeParams;
+import com.asp.android_app.utils.Result;
+import com.asp.android_app.viewmodel.MailViewModel;
+import com.asp.android_app.viewmodel.UserViewModel;
+import com.google.android.material.chip.Chip;
+import com.google.android.material.chip.ChipGroup;
+import com.google.android.material.textfield.MaterialAutoCompleteTextView;
+import com.google.android.material.textfield.TextInputEditText;
+
+import java.util.ArrayList;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Objects;
+import java.util.Set;
+
+/**
+ * ComposeFragment is the mail writer screen.
+ * In this version I added attachment picking using ACTION_OPEN_DOCUMENT (multi-select).
+ * Picked files are converted to Base64 Data URIs and attached to SendMailRequest.
+ */
+public class ComposeFragment extends Fragment {
+
+ private static final String ARG_DRAFT_ID = "arg_draft_id";
+ /**
+ * naive single file hard limit ~2MB
+ */
+ private static final long MAX_FILE_BYTES = 2L * 1024L * 1024L;
+
+ private long draftId = -1;
+
+ private MailViewModel mailVm;
+ private UserViewModel userVm;
+
+ private MaterialAutoCompleteTextView etTo;
+ private ChipGroup chipsRecipients;
+ private TextInputEditText etSubject, etBody;
+ private ChipGroup chipsAttachments;
+ private Button btnAddAttachment;
+
+ private final List selectedRecipients = new ArrayList<>();
+ private final Set selectedMails = new HashSet<>(); // for quick duplicate checks
+
+ /**
+ * holds the attachments to send; reflected in chipsAttachments
+ */
+ private final List files = new ArrayList<>();
+
+ private SuggestionAdapter suggestionAdapter;
+ private final Handler debounceHandler = new Handler(Looper.getMainLooper());
+ private Runnable pendingSearch;
+
+ /**
+ * Tracks whether the last mail action was a send or a save draft
+ */
+ private boolean lastActionWasDraft = false;
+
+ // incoming prefill
+ @Nullable
+ private ComposeParams prefill;
+ // reply/forward HTML block
+ @Nullable
+ private String quotedHtml;
+
+ /**
+ * Launcher for the system document picker. I use ACTION_OPEN_DOCUMENT so the user can
+ * pick from Drive/Downloads/etc and we persist permission for reuse while the draft lives.
+ */
+ private final ActivityResultLauncher pickFilesLauncher =
+ registerForActivityResult(new ActivityResultContracts.StartActivityForResult(), result -> {
+ if (result.getResultCode() != RESULT_OK || result.getData() == null) return;
+
+ Intent data = result.getData();
+ if (data.getClipData() != null) {
+ ClipData clip = data.getClipData();
+ for (int i = 0; i < clip.getItemCount(); i++) {
+ handlePickedUri(clip.getItemAt(i).getUri());
+ }
+ } else if (data.getData() != null) {
+ handlePickedUri(data.getData());
+ }
+ });
+
+ /**
+ * Factory method to create the fragment with an optional draft id.
+ */
+ public static ComposeFragment newInstance(long draftId) {
+ Bundle b = new Bundle();
+ b.putLong(ARG_DRAFT_ID, draftId);
+ ComposeFragment f = new ComposeFragment();
+ f.setArguments(b);
+ return f;
+ }
+
+ @Nullable
+ @Override
+ public View onCreateView(@NonNull LayoutInflater inflater,
+ @Nullable ViewGroup container,
+ @Nullable Bundle savedInstanceState) {
+ return inflater.inflate(R.layout.compose_fragment, container, false);
+ }
+
+ @Override
+ public void onViewCreated(@NonNull View v, @Nullable Bundle savedInstanceState) {
+ super.onViewCreated(v, savedInstanceState);
+
+ // init view models
+ initializeMailViewModel();
+ initializeUserViewModel();
+
+ // Views
+ etTo = v.findViewById(R.id.et_to);
+ chipsRecipients = v.findViewById(R.id.chips_recipients);
+ etSubject = v.findViewById(R.id.et_subject);
+ etBody = v.findViewById(R.id.et_body);
+ chipsAttachments = v.findViewById(R.id.chips_attachments);
+ btnAddAttachment = v.findViewById(R.id.btn_add_attachment);
+
+ // Suggestions adapter
+ suggestionAdapter = new SuggestionAdapter(requireContext());
+ etTo.setAdapter(suggestionAdapter);
+ etTo.setThreshold(1); // show after 1 char
+
+ // Handle selection from dropdown
+ etTo.setOnItemClickListener((parent, view, pos, id) -> {
+ if (suggestionAdapter.getItem(pos) == null) return;
+ addRecipient(new UserInfo(Objects.requireNonNull(suggestionAdapter.getItem(pos))));
+ etTo.setText("");
+ etTo.dismissDropDown();
+ });
+
+ // Debounced search-on-typing
+ etTo.addTextChangedListener(new TextWatcher() {
+ @Override
+ public void beforeTextChanged(CharSequence s, int start, int count, int after) {
+ }
+
+ @Override
+ public void onTextChanged(CharSequence s, int start, int before, int count) {
+ }
+
+ @Override
+ public void afterTextChanged(Editable s) {
+ String q = s.toString().trim();
+ if (pendingSearch != null) debounceHandler.removeCallbacks(pendingSearch);
+ if (q.isBlank()) {
+ suggestionAdapter.setData(null);
+ return;
+ }
+ pendingSearch = () -> userVm.searchUsers(q);
+ debounceHandler.postDelayed(pendingSearch, 300);
+ }
+ });
+
+ // Add attachments button
+ btnAddAttachment.setOnClickListener(v1 -> openSystemPicker());
+
+ // Load draft if needed (do after VM init)
+ draftId = getArguments() != null ? getArguments().getLong(ARG_DRAFT_ID, -1) : -1;
+ if (draftId != -1) mailVm.fetchMailById((int) draftId);
+
+ // Sends the mail on click
+ Button btnSend = v.findViewById(R.id.btn_send);
+ btnSend.setOnClickListener(view -> {
+ lastActionWasDraft = false;
+ SendMailRequest req = buildRequest(false);
+ if (req == null) return; // validation failed
+ mailVm.sendNewMail(req);
+ });
+
+ // Saves the mail as a draft on click
+ Button btnSave = v.findViewById(R.id.btn_save);
+ btnSave.setOnClickListener(view -> {
+ lastActionWasDraft = true;
+ SendMailRequest req = buildRequest(true);
+ if (req == null) return;
+ if (draftId != -1)
+ mailVm.updateDraft((int) draftId, req);
+ else
+ mailVm.sendNewMail(req); // backend supports creating a new draft via POST with saveAsDraft=true
+ });
+
+ // update fields using the prefill params
+ Intent host = requireActivity().getIntent();
+ prefill = host.getParcelableExtra(ComposeMailActivity.EXTRA_PREFILL);
+ if (prefill != null) {
+ // recipients are UserInfo objects → use your existing addRecipient(UserInfo)
+ if (prefill.recipients != null)
+ for (UserInfo u : prefill.recipients)
+ if (u != null && u.getMail() != null) addRecipient(u);
+
+ if (prefill.subject != null) etSubject.setText(prefill.subject);
+ quotedHtml = prefill.quotedHtml;
+
+ if (prefill.files != null && !prefill.files.isEmpty())
+ for (File a : prefill.files) {
+ files.add(a);
+ addAttachmentChip(a);
+ }
+ }
+
+ }
+
+ /**
+ * Opens the platform file picker and allows user to select multiple files.
+ * Using ACTION_OPEN_DOCUMENT (not GET_CONTENT) so we can persist the URI.
+ */
+ private void openSystemPicker() {
+ Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT);
+ intent.addCategory(Intent.CATEGORY_OPENABLE);
+ intent.putExtra(Intent.EXTRA_ALLOW_MULTIPLE, true);
+ intent.setType("*/*"); // let the user choose any file
+ pickFilesLauncher.launch(intent);
+ }
+
+ /**
+ * Handles one picked file URI:
+ * 1) Takes persistable read permission
+ * 2) Checks size
+ * 3) Reads into Base64 Data URI via Base64Converter
+ * 4) Adds to chips + internal list if not duplicate
+ */
+ private void handlePickedUri(@NonNull Uri uri) {
+ try {
+ // persist permission for the lifetime of this draft session
+ final int flag = Intent.FLAG_GRANT_READ_URI_PERMISSION;
+ requireContext().getContentResolver().takePersistableUriPermission(uri, flag);
+
+ String displayName = queryDisplayName(uri);
+ long size = querySize(uri);
+ if (size > MAX_FILE_BYTES) {
+ Toast.makeText(requireContext(), R.string.err_file_too_large, Toast.LENGTH_SHORT).show();
+ return;
+ }
+
+ String dataUri = Base64Converter.fileUriToBase64(uri, requireContext());
+ if (dataUri.isEmpty()) {
+ Toast.makeText(requireContext(), R.string.err_attachment_failed, Toast.LENGTH_SHORT).show();
+ return;
+ }
+
+ File att = new File(displayName != null ? displayName : "file", dataUri);
+ if (!files.contains(att)) {
+ files.add(att);
+ addAttachmentChip(att);
+ }
+ } catch (SecurityException se) {
+ // Some providers don't allow persist; still try to read immediately
+ String displayName = queryDisplayName(uri);
+ String dataUri = Base64Converter.fileUriToBase64(uri, requireContext());
+ if (!dataUri.isEmpty()) {
+ File att = new File(displayName != null ? displayName : "file", dataUri);
+ if (!files.contains(att)) {
+ files.add(att);
+ addAttachmentChip(att);
+ }
+ } else {
+ Toast.makeText(requireContext(), R.string.err_attachment_failed, Toast.LENGTH_SHORT).show();
+ }
+ } catch (Exception e) {
+ Toast.makeText(requireContext(), R.string.err_attachment_failed, Toast.LENGTH_SHORT).show();
+ }
+ }
+
+ /**
+ * Renders a single attachment as a Material chip with an icon + close (remove).
+ */
+ private void addAttachmentChip(@NonNull File att) {
+ Chip chip = new Chip(requireContext());
+ chip.setText(att.getName());
+ int iconRes = Base64Converter.getFileIconResource(att.getName());
+ chip.setChipIconResource(iconRes);
+ chip.setCloseIconVisible(true);
+ chip.setOnCloseIconClickListener(v -> {
+ chipsAttachments.removeView(chip);
+ files.remove(att);
+ });
+ chipsAttachments.addView(chip);
+ }
+
+ /**
+ * Initializes the mail view model and its observers.
+ * When a draft loads, we pre-fill subject/body/recipients and also render any saved attachments.
+ */
+ private void initializeMailViewModel() {
+ mailVm = new ViewModelProvider(this).get(MailViewModel.class);
+
+ mailVm.getMailLiveData().observe(getViewLifecycleOwner(), result -> {
+ if (result instanceof Result.Success) {
+ Mail draft = ((Result.Success) result).getData();
+ etSubject.setText(draft.getSubject());
+ etBody.setText(draft.getBody());
+
+ if (draft.getSentTo() != null)
+ for (UserInfo u : draft.getSentTo()) addRecipient(u);
+
+ // If the draft already has attachments, show them
+ if (draft.getAttachments() != null && !draft.getAttachments().isEmpty()) {
+ files.clear();
+ files.addAll(draft.getAttachments());
+ chipsAttachments.removeAllViews();
+ for (File a : files) addAttachmentChip(a);
+ }
+
+ } else if (result instanceof Result.Error) {
+ Toast.makeText(getContext(), ((Result.Error) result).getMessage(), Toast.LENGTH_SHORT).show();
+ }
+ });
+
+ mailVm.getSendMailStatus().observe(getViewLifecycleOwner(), result -> {
+ if (result instanceof Result.Success) {
+ Intent data = new Intent();
+ data.putExtra("compose_result_action", lastActionWasDraft ? "saved" : "sent");
+ requireActivity().setResult(RESULT_OK, data);
+ requireActivity().finish(); // closes ComposeMailActivity and delivers the result
+
+ } else if (result instanceof Result.Error) {
+ Toast.makeText(requireContext(), ((Result.Error>) result).getMessage(), Toast.LENGTH_LONG).show();
+ }
+ });
+ }
+
+ /**
+ * Initializes the user view model and it's observers (autocomplete suggestions).
+ */
+ private void initializeUserViewModel() {
+ userVm = new ViewModelProvider(this).get(UserViewModel.class);
+
+ userVm.getSearchResults().observe(getViewLifecycleOwner(), result -> {
+ if (result instanceof Result.Success) {
+ List found = ((Result.Success>) result).getData();
+ List filtered = new ArrayList<>();
+ for (UserSearchResult u : found)
+ if (!selectedMails.contains(u.getMail())) filtered.add(u);
+
+ suggestionAdapter.setData(filtered);
+ if (!filtered.isEmpty()) etTo.showDropDown();
+
+ } else if (result instanceof Result.Error) {
+ String msg = ((Result.Error>) result).getMessage();
+ Toast.makeText(getContext(), msg, Toast.LENGTH_SHORT).show();
+ }
+ });
+ }
+
+ /**
+ * Adds a recipient chip if not already selected.
+ *
+ * @param user new user object to add
+ */
+ private void addRecipient(@NonNull UserInfo user) {
+ if (selectedMails.contains(user.getMail())) return;
+ selectedRecipients.add(user);
+ selectedMails.add(user.getMail());
+
+ Chip chip = new Chip(requireContext());
+ chip.setText(user.toString());
+ chip.setCloseIconVisible(true);
+ chip.setOnCloseIconClickListener(v -> {
+ chipsRecipients.removeView(chip);
+ selectedMails.remove(user.getMail());
+ selectedRecipients.remove(user);
+ });
+ chipsRecipients.addView(chip);
+ }
+
+ /**
+ * Builds the send/save request with current fields and the selected attachments.
+ * Very basic validation - at least 1 recipient when sending (not required for drafts).
+ * if we have quotedHtml (reply/forward) we will send as: typed text + quoted block (HTML)
+ */
+ @Nullable
+ private SendMailRequest buildRequest(boolean saveAsDraft) {
+ String subject = safeText(etSubject);
+ String typed = safeText(etBody);
+
+ if (!saveAsDraft && selectedRecipients.isEmpty()) {
+ Toast.makeText(requireContext(), R.string.err_min_recipients, Toast.LENGTH_SHORT).show();
+ return null;
+ }
+
+ List sentTo = new ArrayList<>();
+ for (UserInfo u : selectedRecipients)
+ sentTo.add(u.getMail());
+ // If any raw emails were added (selectedMails), include them too (guards edge-cases)
+ for (String m : selectedMails)
+ if (!sentTo.contains(m)) sentTo.add(m);
+
+ // Final HTML body
+ String body = com.asp.android_app.utils.MailHtmlUtil.mergeTypedWithQuote(typed, quotedHtml);
+
+ // pass the attachments that were added
+ return new SendMailRequest(subject, body, sentTo, saveAsDraft, new ArrayList<>(files));
+ }
+
+ /**
+ * Gets string input from edit text safely, default value is an empty string
+ *
+ * @param et edit text field to get input from
+ * @return the input from the user, if an error occurred will return an empty string
+ */
+ private String safeText(@Nullable TextInputEditText et) {
+ try {
+ return et != null && et.getText() != null ? et.getText().toString() : "";
+ } catch (Exception e) {
+ return "";
+ }
+ }
+
+ /**
+ * Queries a human friendly file name for the given content URI using OpenableColumns.DISPLAY_NAME.
+ */
+ @Nullable
+ private String queryDisplayName(@NonNull Uri uri) {
+ Cursor c = null;
+ try {
+ c = requireContext().getContentResolver().query(uri, null, null, null, null);
+ if (c != null && c.moveToFirst()) {
+ int idx = c.getColumnIndex(OpenableColumns.DISPLAY_NAME);
+ if (idx != -1) return c.getString(idx);
+ }
+ } catch (Exception ignored) {
+ } finally {
+ if (c != null) c.close();
+ }
+ return null;
+ }
+
+ /**
+ * Tries to read file size from the content resolver to enforce a rough limit.
+ * Returns -1 if unknown.
+ */
+ private long querySize(@NonNull Uri uri) {
+ Cursor c = null;
+ try {
+ c = requireContext().getContentResolver().query(uri, null, null, null, null);
+ if (c != null && c.moveToFirst()) {
+ int idx = c.getColumnIndex(OpenableColumns.SIZE);
+ if (idx != -1) return c.getLong(idx);
+ }
+ } catch (Exception ignored) {
+ } finally {
+ if (c != null) c.close();
+ }
+ return -1;
+ }
+}
diff --git a/android_app/app/src/main/java/com/asp/android_app/ui/compose_activity/ComposeMailActivity.java b/android_app/app/src/main/java/com/asp/android_app/ui/compose_activity/ComposeMailActivity.java
new file mode 100644
index 00000000..f5d3f77d
--- /dev/null
+++ b/android_app/app/src/main/java/com/asp/android_app/ui/compose_activity/ComposeMailActivity.java
@@ -0,0 +1,59 @@
+package com.asp.android_app.ui.compose_activity;
+
+import android.content.Context;
+import android.content.Intent;
+import android.os.Bundle;
+
+import androidx.annotation.Nullable;
+import androidx.appcompat.app.AppCompatActivity;
+
+import com.asp.android_app.utils.ComposeParams;
+
+/**
+ * Simple holder Activity for ComposeFragment.
+ * I prefer this so the compose UI can be reused (e.g., reply and forward).
+ */
+public class ComposeMailActivity extends AppCompatActivity {
+
+ /**
+ * Key for passing a draftId. -1 means new mail (not a draft to edit)
+ */
+ public static final String EXTRA_DRAFT_ID = "extra_draft_id";
+ public static final String EXTRA_PREFILL = "extra_prefill";
+
+ /**
+ * Factory to create an Intent for opening compose (optionally with a draftId to edit).
+ */
+ public static Intent newIntent(Context c, long draftId) {
+ Intent i = new Intent(c, ComposeMailActivity.class);
+ i.putExtra(EXTRA_DRAFT_ID, draftId);
+ return i;
+ }
+
+ /**
+ * Factory to create an intent for opening compose for reply/ forward functionalities
+ *
+ * @param context where we called from
+ * @param params object of data to pass when using reply/forward
+ */
+ public static Intent newIntent(Context context, @Nullable ComposeParams params) {
+ Intent i = newIntent(context, -1);
+ if (params != null) i.putExtra(EXTRA_PREFILL, params);
+ return i;
+ }
+
+ @Override
+ public void onCreate(@Nullable Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+
+ if (savedInstanceState == null)
+ getSupportFragmentManager()
+ .beginTransaction()
+ .replace(
+ android.R.id.content,
+ ComposeFragment.newInstance(
+ getIntent().getLongExtra(EXTRA_DRAFT_ID, -1)
+ ))
+ .commit();
+ }
+}
diff --git a/android_app/app/src/main/java/com/asp/android_app/ui/compose_activity/SuggestionAdapter.java b/android_app/app/src/main/java/com/asp/android_app/ui/compose_activity/SuggestionAdapter.java
new file mode 100644
index 00000000..855ed0c7
--- /dev/null
+++ b/android_app/app/src/main/java/com/asp/android_app/ui/compose_activity/SuggestionAdapter.java
@@ -0,0 +1,50 @@
+package com.asp.android_app.ui.compose_activity;
+
+import android.content.Context;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.ArrayAdapter;
+import android.widget.TextView;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+
+import com.asp.android_app.R;
+import com.asp.android_app.model.response.UserSearchResult;
+
+import java.util.ArrayList;
+import java.util.List;
+
+public class SuggestionAdapter extends ArrayAdapter {
+ private final LayoutInflater inflater;
+ private final List items = new ArrayList<>();
+
+ public SuggestionAdapter(@NonNull Context ctx) {
+ super(ctx, 0);
+ inflater = LayoutInflater.from(ctx);
+ }
+
+ public void setData(List data) {
+ items.clear();
+ if (data != null) items.addAll(data);
+ notifyDataSetChanged();
+ }
+
+ @Override public int getCount() { return items.size(); }
+ @Nullable @Override public UserSearchResult getItem(int position) { return items.get(position); }
+
+ @NonNull
+ @Override public View getView(int position, @Nullable View convertView, @NonNull ViewGroup parent) {
+ View v = convertView;
+ if (v == null) v = inflater.inflate(R.layout.item_user_suggestion, parent, false);
+ UserSearchResult u = getItem(position);
+ TextView tvName = v.findViewById(R.id.tv_name);
+ TextView tvMail = v.findViewById(R.id.tv_mail);
+ if (u != null) {
+ tvName.setText(u.getName());
+ tvMail.setText(u.getMail());
+ }
+ return v;
+ }
+}
diff --git a/android_app/app/src/main/java/com/asp/android_app/ui/inbox_activity/InboxActivity.java b/android_app/app/src/main/java/com/asp/android_app/ui/inbox_activity/InboxActivity.java
new file mode 100644
index 00000000..d1931502
--- /dev/null
+++ b/android_app/app/src/main/java/com/asp/android_app/ui/inbox_activity/InboxActivity.java
@@ -0,0 +1,514 @@
+package com.asp.android_app.ui.inbox_activity;
+
+import static com.asp.android_app.utils.Base64Converter.displayBase64Image;
+
+import android.content.Intent;
+import android.os.Bundle;
+import android.os.Handler;
+import android.os.Looper;
+import android.text.Editable;
+import android.text.TextWatcher;
+import android.view.Menu;
+import android.view.MenuItem;
+import android.widget.EditText;
+import android.widget.ImageView;
+import android.widget.Toast;
+
+import androidx.activity.result.ActivityResultLauncher;
+import androidx.appcompat.app.AppCompatActivity;
+import androidx.core.view.GravityCompat;
+import androidx.drawerlayout.widget.DrawerLayout;
+import androidx.lifecycle.ViewModelProvider;
+
+import com.asp.android_app.R;
+import com.asp.android_app.model.Label;
+import com.asp.android_app.ui.compose_activity.ComposeMailActivity;
+import com.asp.android_app.utils.ComposeNavigation;
+import com.asp.android_app.utils.NetworkUtil;
+import com.asp.android_app.utils.TokenManager;
+import com.asp.android_app.model.request.ProfileImageRequest;
+import com.asp.android_app.model.response.UserInfo;
+import com.asp.android_app.ui.auth_activity.AuthActivity;
+import com.asp.android_app.utils.ImagePicker;
+import com.asp.android_app.utils.Result;
+import com.asp.android_app.viewmodel.LabelViewModel;
+import com.asp.android_app.viewmodel.UserViewModel;
+import com.google.android.material.dialog.MaterialAlertDialogBuilder;
+import com.google.android.material.floatingactionbutton.FloatingActionButton;
+import com.google.android.material.navigation.NavigationView;
+import com.google.android.material.snackbar.Snackbar;
+
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+public class InboxActivity extends AppCompatActivity {
+ private InboxFragment inboxFragment;
+ private ActivityResultLauncher imagePickerLauncher;
+ private ImageView userImageView;
+
+ private ActivityResultLauncher readLauncher;
+
+ @Override
+ protected void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ setContentView(R.layout.activity_inbox);
+ UserInfo user = getIntent().getParcelableExtra("user");
+
+ if (savedInstanceState == null) {
+ inboxFragment = new InboxFragment();
+ getSupportFragmentManager()
+ .beginTransaction()
+ .replace(R.id.fragment_container, inboxFragment)
+ .commit();
+ } else {
+ inboxFragment = (InboxFragment) getSupportFragmentManager()
+ .findFragmentById(R.id.fragment_container);
+ }
+
+ // initialize user related observers and data sources
+ UserViewModel userViewModel = new ViewModelProvider(this).get(UserViewModel.class);
+ initializeUserObservers(userViewModel);
+
+ // initialize the profile image
+ userImageView = findViewById(R.id.user_avatar);
+ loadProfileImage(userImageView, user, userViewModel);
+ userImageView.setOnClickListener(view -> showProfileOptionsDialog(user));
+ // initialize the drawer menu and labels
+ LabelViewModel labelViewModel = new ViewModelProvider(this).get(LabelViewModel.class);
+ initializeDrawerMenu(labelViewModel);
+
+ // initialize the search bar
+ EditText searchInput = findViewById(R.id.search_input);
+ searchInput.setSelected(false); // on creation - don't show as focused
+ handleMailSearch(searchInput);
+
+ // initialize the floating compose button
+ initializeComposeButton();
+ }
+
+
+ /**
+ * initializes and sets the click listener for the compose floating action button
+ */
+ private void initializeComposeButton() {
+ ActivityResultLauncher composeLauncher = ComposeNavigation.register(this, new ComposeNavigation.ResultListener() {
+ @Override
+ public void onSent() {
+ if (inboxFragment != null) inboxFragment.setInbox("incoming");
+ Snackbar.make(findViewById(android.R.id.content),
+ R.string.compose_sent_success, Snackbar.LENGTH_SHORT).show();
+ }
+
+ @Override
+ public void onSaved() {
+ if (inboxFragment != null) inboxFragment.setInbox("draft");
+ Snackbar.make(findViewById(android.R.id.content),
+ R.string.compose_saved_success, Snackbar.LENGTH_SHORT).show();
+ }
+ });
+
+ FloatingActionButton fab = findViewById(R.id.fabCompose);
+ fab.setOnClickListener(v -> {
+ if (!NetworkUtil.isOnline(this)) {
+ Toast.makeText(this, R.string.no_connection, Toast.LENGTH_SHORT).show();
+ return;
+ }
+ Intent i = ComposeMailActivity.newIntent(this, -1);
+ composeLauncher.launch(i);
+ });
+ }
+
+ /**
+ * Initializes user related observers like the user view model and image picker click listeners
+ *
+ * @param userViewModel view model containing the single source of truth
+ */
+ private void initializeUserObservers(UserViewModel userViewModel) {
+ userViewModel.getUserInfoResult().observe(this, result -> {
+ if (result instanceof Result.Success) {
+ UserInfo updatedUser = ((Result.Success) result).getData();
+ displayBase64Image(updatedUser.getImageUrl(), userImageView);
+ } else if (result instanceof Result.Error) {
+ Toast.makeText(this, ((Result.Error>) result).getMessage(), Toast.LENGTH_SHORT).show();
+ }
+ });
+
+ userViewModel.getImageUpdateStatus().observe(this, result -> {
+ if (result instanceof Result.Success) {
+ UserInfo updatedUser = ((Result.Success) result).getData();
+ displayBase64Image(updatedUser.getImageUrl(), userImageView);
+ } else if (result instanceof Result.Error) {
+ Toast.makeText(this, ((Result.Error>) result).getMessage(), Toast.LENGTH_SHORT).show();
+ }
+ });
+
+ imagePickerLauncher = ImagePicker.registerImagePicker(this, this,
+ new ImagePicker.ImagePickerCallback() {
+ @Override
+ public void onImagePicked(String base64Image) {
+ UserInfo user = getIntent().getParcelableExtra("user");
+ if (user != null) {
+ ProfileImageRequest request = new ProfileImageRequest(base64Image);
+ userViewModel.changeProfileImage(user.getId(), request);
+ }
+ }
+
+ @Override
+ public void onError(String message) {
+ Toast.makeText(InboxActivity.this, message, Toast.LENGTH_SHORT).show();
+ }
+ });
+ }
+
+ /**
+ * Loads the user image using base64, if no image found uses the default avatar
+ *
+ * @param userImageView image view instance to show on
+ */
+ private void loadProfileImage(ImageView userImageView, UserInfo user, UserViewModel userVM) {
+ if (!NetworkUtil.isOnline(this)) {
+ Toast.makeText(this, R.string.no_connection, Toast.LENGTH_SHORT).show();
+ return;
+ }
+
+ if (user != null) {
+ userVM.fetchUserInfo(user.getId());
+ } else {
+ userImageView.setImageResource(R.drawable.profile_default);
+ }
+ }
+
+ private void showProfileOptionsDialog(UserInfo user) {
+ new MaterialAlertDialogBuilder(this)
+ .setTitle(R.string.user_settings)
+ .setItems(new String[]{
+ getString(R.string.change_picture),
+ getString(R.string.logout_user)
+ }, (dialogInterface, i) -> {
+ if (i == 0) {
+ // Change profile picture
+ imagePickerLauncher.launch("image/*");
+ } else if (i == 1) {
+ // Log out
+ TokenManager tokenManager = TokenManager.getInstance(this);
+ tokenManager.clearToken();
+ Intent intent = new Intent(this, AuthActivity.class);
+ startActivity(intent);
+ }
+ })
+ .show();
+ }
+
+ /**
+ * Initializes the drawer layout and listens to clicks to show the type of inbox
+ *
+ * @param labelViewModel view model containing the single source of truth
+ */
+ private void initializeDrawerMenu(LabelViewModel labelViewModel) {
+ DrawerLayout drawerLayout = findViewById(R.id.main);
+ ImageView hamburgerIcon = findViewById(R.id.hamburger_icon);
+ hamburgerIcon.setOnClickListener(v -> {
+ drawerLayout.openDrawer(GravityCompat.START);
+ });
+
+ NavigationView navView = findViewById(R.id.navigation_view);
+ navView.setNavigationItemSelectedListener(item -> {
+ clearAllMenuSelection();
+ int id = item.getItemId();
+ drawerLayout.closeDrawer(GravityCompat.START);
+ item.setChecked(true);
+
+ if (id == R.id.nav_all_mails) {
+ inboxFragment.setInbox("all");
+ return true;
+ }
+ if (id == R.id.nav_incoming) {
+ inboxFragment.setInbox("incoming");
+ return true;
+ }
+ if (id == R.id.nav_sent) {
+ inboxFragment.setInbox("sent");
+ return true;
+ }
+ if (id == R.id.nav_draft) {
+ inboxFragment.setInbox("draft");
+ return true;
+ }
+ if (id == R.id.nav_star) {
+ inboxFragment.setInbox("star");
+ return true;
+ }
+ if (id == R.id.nav_trash) {
+ inboxFragment.setInbox("trash");
+ return true;
+ }
+ if (id == R.id.nav_spam) {
+ inboxFragment.setInbox("spam");
+ return true;
+ }
+ if (id == R.id.nav_create_label) {
+ if (!NetworkUtil.isOnline(this)) {
+ Toast.makeText(this, R.string.no_connection, Toast.LENGTH_SHORT).show();
+ return true;
+ }
+ showCreateLabelDialog(labelViewModel);
+ return true;
+ }
+ return false;
+ });
+ // init labels
+ labelViewModel.fetchAllLabels();
+ initializeLabels(labelViewModel);
+ }
+
+ /**
+ * Adds a text change listener to the search input field and triggers
+ * the mail search after user types (with a small delay).
+ *
+ * @param searchInput the EditText for entering search queries
+ */
+ private void handleMailSearch(EditText searchInput) {
+ final Handler handler = new Handler(Looper.getMainLooper());
+ final long delayMillis = 300; // debounce time
+ final Runnable[] searchRunnable = new Runnable[1];
+
+ searchInput.addTextChangedListener(new TextWatcher() {
+ @Override
+ public void beforeTextChanged(CharSequence charSequence, int i, int i1, int i2) {
+
+ }
+
+ @Override
+ public void onTextChanged(CharSequence s, int start, int before, int count) {
+ handler.removeCallbacks(searchRunnable[0]);
+
+ }
+
+ @Override
+ public void afterTextChanged(Editable editable) {
+ String query = editable.toString().trim();
+ searchRunnable[0] = () -> {
+ if (inboxFragment != null) {
+ inboxFragment.searchMails(query);
+ }
+ };
+ handler.postDelayed(searchRunnable[0], delayMillis);
+ }
+ });
+ }
+
+ /**
+ * Initializes the labels in the drawer menu
+ *
+ * @param labelViewModel view model containing the single source of truth
+ */
+ private void initializeLabels(LabelViewModel labelViewModel) {
+ // observer for fetching labels
+ labelViewModel.getAllLabels().observe(this, result -> {
+ if (result == null) {
+ return;
+ }
+ populateLabelsInDrawer(result);
+ });
+ // observer for label creation
+ labelViewModel.getCreatedLabel().observe(this, result -> {
+ if (result == null)
+ return;
+ labelViewModel.fetchAllLabels();
+ });
+ labelViewModel.getCreatedSublabel().observe(this, result -> {
+ if (result == null)
+ return;
+ labelViewModel.fetchAllLabels();
+ });
+ // observer for editing
+ labelViewModel.getLabelUpdated().observe(this, result -> {
+ if (result == null)
+ return;
+ labelViewModel.fetchAllLabels();
+ });
+ // observer for deletion
+ labelViewModel.getLabelDeleted().observe(this, result -> {
+ if (result == null)
+ return;
+ labelViewModel.fetchAllLabels();
+ });
+ }
+
+ /**
+ * Adds the given labels to the drawer menu
+ *
+ * @param labels list of labels from the backend
+ */
+ private void populateLabelsInDrawer(List labels) {
+ NavigationView navView = findViewById(R.id.navigation_view);
+ Menu menu = navView.getMenu();
+
+ // Remove any previous dynamic labels
+ for (int i = menu.size() - 1; i >= 0; i--) {
+ MenuItem item = menu.getItem(i);
+ if (item.getGroupId() == R.id.nav_dynamic_labels_group)
+ menu.removeItem(item.getItemId());
+ }
+
+ // Use a high `order` value for the "Create Label" item to ensure it's always last
+ MenuItem createLabelItem = menu.findItem(R.id.nav_create_label);
+ if (createLabelItem != null)
+ menu.removeItem(R.id.nav_create_label);
+
+ Map> childrenMap = new HashMap<>();
+ List rootLabels = new ArrayList<>();
+
+ for (Label label : labels) {
+ if (label.getParent() == null) {
+ rootLabels.add(label);
+ continue;
+ }
+ childrenMap
+ .computeIfAbsent(label.getParent(), k -> new ArrayList<>())
+ .add(label);
+ }
+
+ // Add dynamic labels with a mid-range order value (after inboxes, before "create label")
+ int[] orderCounter = {0}; // use array to allow mutation inside lambda
+ for (Label parent : rootLabels) {
+ addLabelToMenu(menu, parent, 0, orderCounter, childrenMap);
+ }
+
+ // Re-add "Create Label" with a higher order so it would be last
+ if (createLabelItem != null) {
+ menu.add(
+ Menu.NONE,
+ R.id.nav_create_label,
+ orderCounter[0] + labels.size() + 10,
+ createLabelItem.getTitle())
+ .setIcon(R.drawable.ic_add);
+ }
+ }
+
+ /**
+ * Adds a new label to the drawer menu
+ *
+ * @param menu menu to be inserted to
+ * @param label label object to insert
+ * @param level indentation level of the label (0 for root)
+ * @param orderCounter counter to place labels in correct order (sub label after parent)
+ * @param childrenMap map to track sub labels correctly
+ */
+ private void addLabelToMenu(
+ Menu menu,
+ Label label,
+ int level,
+ int[] orderCounter,
+ Map> childrenMap
+ ) {
+ String indent = " ".repeat(level);
+ String labelName = indent + label.getName();
+
+ MenuItem item = menu.add(R.id.nav_dynamic_labels_group, Menu.NONE, 100 + orderCounter[0]++, labelName);
+ item.setIcon(R.drawable.ic_label);
+ item.setOnMenuItemClickListener(menuItem -> {
+ clearAllMenuSelection();
+ menuItem.setChecked(true);
+ inboxFragment.setInbox("label:" + label.getId());
+ DrawerLayout drawerLayout = findViewById(R.id.main);
+ drawerLayout.closeDrawer(GravityCompat.START);
+ return true;
+ });
+
+ List children = childrenMap.get(label.getId());
+ if (children != null)
+ for (Label child : children)
+ addLabelToMenu(menu, child, level + 1, orderCounter, childrenMap);
+ }
+
+ /**
+ * Unchecks all items in the drawer menu (static and dynamic).
+ */
+ private void clearAllMenuSelection() {
+ NavigationView navView = findViewById(R.id.navigation_view);
+ Menu menu = navView.getMenu();
+
+ for (int i = 0; i < menu.size(); i++) {
+ MenuItem item = menu.getItem(i);
+ item.setChecked(false);
+ // Handle nested groups like nav_dynamic_labels_group
+ if (item.hasSubMenu())
+ for (int j = 0; j < item.getSubMenu().size(); j++)
+ item.getSubMenu().getItem(j).setChecked(false);
+ }
+ }
+
+ /**
+ * Shows a dialog to create a new label
+ *
+ * @param labelViewModel view model containing the single source of truth
+ */
+ private void showCreateLabelDialog(LabelViewModel labelViewModel) {
+ final EditText etName = new EditText(this);
+ etName.setHint(R.string.label_hint);
+ new MaterialAlertDialogBuilder(this)
+ .setTitle(R.string.new_label)
+ .setView(etName)
+ .setPositiveButton(R.string.next, (dialog, which) -> {
+ String labelName = etName.getText().toString().trim();
+ if (labelName.isBlank()) {
+ Toast.makeText(this, R.string.name_required_error, Toast.LENGTH_SHORT).show();
+ return;
+ }
+ showSublabelChoiceDialog(labelViewModel, labelName);
+ })
+ .setNegativeButton(R.string.cancel, null)
+ .show();
+ }
+
+ /**
+ * Shows a dialog to create a new sub label
+ *
+ * @param labelViewModel view model containing the single source of truth
+ * @param labelName new sub label's name
+ */
+ private void showSublabelChoiceDialog(LabelViewModel labelViewModel, String labelName) {
+ String[] options = getResources().getStringArray(R.array.label_options);
+ List currentLabels = labelViewModel.getAllLabels().getValue();
+ // if no labels exist yet - just create the label as a parent label
+ if (currentLabels == null || currentLabels.isEmpty()) {
+ labelViewModel.createLabel(labelName);
+ labelViewModel.getAllLabels();
+ return;
+ }
+ // let the user pick if it's a parent label or sub label
+ new MaterialAlertDialogBuilder(this)
+ .setTitle(R.string.create_label_as)
+ .setItems(options, (dialog, which) -> {
+ if (which == 0) {
+ labelViewModel.createLabel(labelName);
+ labelViewModel.getAllLabels();
+ } else {
+ // Open another dialog with list of existing labels
+ showParentPickerDialog(labelViewModel, labelName, currentLabels);
+ }
+ })
+ .show();
+ }
+
+ /**
+ * Shows a dialog to pick a parent label for a sub label
+ *
+ * @param labelVM view model containing the single source of truth
+ * @param name new sub label's name
+ */
+ private void showParentPickerDialog(LabelViewModel labelVM, String name, List labels) {
+ String[] labelNames = labels.stream().map(Label::getName).toArray(String[]::new);
+ new MaterialAlertDialogBuilder(this)
+ .setTitle(R.string.pick_parent_label)
+ .setItems(labelNames, (dialog, which) -> {
+ int parentId = labels.get(which).getId();
+ labelVM.createSublabel(parentId, name);
+ })
+ .show();
+ }
+
+}
\ No newline at end of file
diff --git a/android_app/app/src/main/java/com/asp/android_app/ui/inbox_activity/InboxFragment.java b/android_app/app/src/main/java/com/asp/android_app/ui/inbox_activity/InboxFragment.java
new file mode 100644
index 00000000..9a62adb9
--- /dev/null
+++ b/android_app/app/src/main/java/com/asp/android_app/ui/inbox_activity/InboxFragment.java
@@ -0,0 +1,578 @@
+package com.asp.android_app.ui.inbox_activity;
+
+import static android.view.View.GONE;
+import static android.view.View.VISIBLE;
+
+import android.app.Activity;
+import android.content.Intent;
+import android.os.Bundle;
+import android.util.Log;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.view.WindowManager;
+import android.widget.Button;
+import android.widget.ImageButton;
+import android.widget.Toast;
+
+import androidx.activity.result.ActivityResultLauncher;
+import androidx.activity.result.contract.ActivityResultContracts;
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.appcompat.app.AlertDialog;
+import androidx.fragment.app.Fragment;
+import androidx.lifecycle.ViewModelProvider;
+import androidx.recyclerview.widget.LinearLayoutManager;
+import androidx.recyclerview.widget.RecyclerView;
+import androidx.swiperefreshlayout.widget.SwipeRefreshLayout;
+
+import com.asp.android_app.R;
+import com.asp.android_app.model.Label;
+import com.asp.android_app.model.Mail;
+import com.asp.android_app.model.request.SpamRequest;
+import com.asp.android_app.model.response.MailListResponse;
+import com.asp.android_app.utils.ComposeNavigation;
+import com.asp.android_app.utils.Result;
+import com.asp.android_app.viewmodel.LabelViewModel;
+import com.asp.android_app.viewmodel.MailViewModel;
+import com.google.android.material.dialog.MaterialAlertDialogBuilder;
+import com.google.android.material.floatingactionbutton.FloatingActionButton;
+import com.google.android.material.snackbar.Snackbar;
+import android.widget.LinearLayout;
+import android.widget.TextView;
+import android.widget.ImageView;
+import com.google.android.material.button.MaterialButton;
+
+import java.util.ArrayList;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+
+/**
+ * Fragment containing the inbox, responsible on the showing the mails list, handling clicks,
+ * selection and refreshes.
+ */
+public class InboxFragment extends Fragment {
+
+ private MailViewModel mailViewModel;
+ private MailAdapter mailAdapter;
+ private String inboxType = "incoming"; // default inbox is incoming mails
+ private final ActivityResultLauncher readingLauncher =
+ registerForActivityResult(new ActivityResultContracts.StartActivityForResult(), result -> {
+ if (result.getResultCode() == Activity.RESULT_OK && result.getData() != null) {
+ boolean shouldRefresh = result.getData().getBooleanExtra("refresh", false);
+ String returnedInboxType = result.getData().getStringExtra("inboxType");
+ if (shouldRefresh && returnedInboxType != null && returnedInboxType.equals(inboxType)) {
+ mailViewModel.loadMails(inboxType); // refresh only if same inbox
+ }
+ }
+ });
+
+ private boolean isLoading = false;
+ private boolean hasMoreData = true;
+ private int PAGE_LIMIT = 50;
+
+ private LinearLayout emptyStateContainer;
+ private TextView emptyStateTitle;
+ private TextView emptyStateSubtitle;
+ private ImageView emptyStateIcon;
+ private MaterialButton emptyStateActionButton;
+ private MaterialButton emptyStateRefreshButton;
+
+ @Nullable
+ @Override
+ public View onCreateView(@NonNull LayoutInflater inflater,
+ @Nullable ViewGroup container,
+ @Nullable Bundle savedInstanceState) {
+ View view = inflater.inflate(R.layout.inbox_fragment, container, false);
+
+ // initialize the mails view model
+ mailViewModel = new ViewModelProvider(this).get(MailViewModel.class);
+ mailViewModel.loadMails(inboxType);
+
+ initializeActionBar();
+
+ ActivityResultLauncher composeLauncher = ComposeNavigation.register(this, new ComposeNavigation.ResultListener() {
+ @Override
+ public void onSent() {
+ setInbox("incoming");
+ showSnack(R.string.compose_sent_success);
+ }
+
+ @Override
+ public void onSaved() {
+ setInbox("draft");
+ showSnack(R.string.compose_saved_success);
+ }
+ });
+
+ // initialize the recycler view
+ RecyclerView recyclerView = view.findViewById(R.id.recyclerView);
+ MailSelectionListener selectionListener = hasSelection -> {
+ requireActivity().runOnUiThread(() -> {
+ View customBar = requireActivity().findViewById(R.id.custom_toolbar);
+ View actionBar = requireActivity().findViewById(R.id.action_bar);
+ customBar.setVisibility(hasSelection ? GONE : VISIBLE);
+ actionBar.setVisibility(hasSelection ? VISIBLE : GONE);
+ });
+ };
+ mailAdapter = new MailAdapter(
+ requireContext(),
+ getViewLifecycleOwner(),
+ inboxType,
+ readingLauncher,
+ mailViewModel,
+ selectionListener,
+ composeLauncher
+ );
+ recyclerView.setAdapter(mailAdapter);
+ recyclerView.setLayoutManager(new LinearLayoutManager(getContext()));
+ initializeEmptyState(view);
+ setupPaginationScrollListener(recyclerView);
+
+ // initialize the swiping down for refresh
+ SwipeRefreshLayout swipeRefreshLayout = view.findViewById(R.id.swipeRefreshLayout);
+ swipeRefreshLayout.setOnRefreshListener(() -> {
+ // Trigger refresh from ViewModel
+ String loadInbox = inboxType;
+ if (loadInbox == null || loadInbox.isBlank())
+ loadInbox = "incoming";
+ mailViewModel.loadMails(loadInbox);
+ });
+ observeViewModel(swipeRefreshLayout);
+
+ return view;
+ }
+
+ /**
+ * Sets up the pagination listeners to load in a 'lazy loading' way
+ *
+ * @param recyclerView the recycler view to load into
+ */
+ private void setupPaginationScrollListener(RecyclerView recyclerView) {
+ recyclerView.addOnScrollListener(new RecyclerView.OnScrollListener() {
+ @Override
+ public void onScrolled(@NonNull RecyclerView recyclerView, int dx, int dy) {
+ super.onScrolled(recyclerView, dx, dy);
+
+ LinearLayoutManager layoutManager = (LinearLayoutManager) recyclerView.getLayoutManager();
+ if (layoutManager != null && !isLoading && hasMoreData && dy > 0) {
+ int visibleItemCount = layoutManager.getChildCount();
+ int totalItemCount = layoutManager.getItemCount();
+ int firstVisibleItemPosition = layoutManager.findFirstVisibleItemPosition();
+
+ // Load more when we're 5 items from the bottom
+ if ((visibleItemCount + firstVisibleItemPosition + 5) >= totalItemCount) {
+ loadNextPage();
+ }
+ }
+ }
+ });
+ }
+
+ /**
+ * Loads the next batch of mails
+ */
+ private void loadNextPage() {
+ if (isLoading || !hasMoreData) return;
+
+ isLoading = true;
+ if (inboxType.startsWith("label:")) {
+ int labelId = Integer.parseInt(inboxType.substring("label:".length()));
+ mailViewModel.nextPage("label:" + labelId);
+ mailViewModel.loadMoreMailsByLabel(labelId);
+ //mailViewModel.loadMailsByLabel(labelId);
+ } else {
+ mailViewModel.nextPage(inboxType);
+ mailViewModel.loadMoreMails(inboxType);
+ //mailViewModel.loadMails(inboxType);
+ }
+ }
+
+ /**
+ * initializes the alternative view of an empty inbox, showing dynamic message depending on the
+ * inbox's type
+ *
+ * @param view root view object
+ */
+ private void initializeEmptyState(View view) {
+ emptyStateContainer = view.findViewById(R.id.empty_state_container);
+ emptyStateTitle = view.findViewById(R.id.empty_state_title);
+ emptyStateSubtitle = view.findViewById(R.id.empty_state_subtitle);
+ emptyStateIcon = view.findViewById(R.id.empty_state_icon);
+ emptyStateActionButton = view.findViewById(R.id.empty_state_action_button);
+ emptyStateRefreshButton = view.findViewById(R.id.empty_state_refresh_button);
+
+ // Set up refresh button click listener
+ emptyStateRefreshButton.setOnClickListener(v -> {
+ String loadInbox = inboxType;
+ if (loadInbox == null || loadInbox.isBlank()) {
+ loadInbox = "incoming";
+ }
+ mailViewModel.loadMails(loadInbox);
+ });
+
+ // Set up action button click listener (for compose)
+ emptyStateActionButton.setOnClickListener(v -> {
+ // Trigger the compose action - you might want to reference your composeLauncher here
+ // For now, we'll make it trigger the FAB click
+ Activity activity = getActivity();
+ if (activity != null) {
+ FloatingActionButton fab = activity.findViewById(R.id.fabCompose);
+ if (fab != null) {
+ fab.performClick();
+ }
+ }
+ });
+ }
+
+ /**
+ * updates the content according to the inbox's type and state
+ *
+ * @param inboxType inbox's type
+ */
+ private void updateEmptyStateContent(String inboxType) {
+ if (emptyStateTitle == null || emptyStateSubtitle == null || emptyStateIcon == null) {
+ return;
+ }
+
+ switch (inboxType) {
+ case "incoming":
+ emptyStateTitle.setText(R.string.empty_inbox_title);
+ emptyStateSubtitle.setText(R.string.empty_inbox_subtitle);
+ emptyStateIcon.setImageResource(R.drawable.ic_mail);
+ emptyStateActionButton.setVisibility(VISIBLE);
+ emptyStateActionButton.setText(R.string.compose_new_mail);
+ break;
+
+ case "sent":
+ emptyStateTitle.setText(R.string.empty_sent_title);
+ emptyStateSubtitle.setText(R.string.empty_sent_subtitle);
+ emptyStateIcon.setImageResource(R.drawable.ic_send);
+ emptyStateActionButton.setVisibility(VISIBLE);
+ emptyStateActionButton.setText(R.string.compose_new_mail);
+ break;
+
+ case "draft":
+ emptyStateTitle.setText(R.string.empty_draft_title);
+ emptyStateSubtitle.setText(R.string.empty_draft_subtitle);
+ emptyStateIcon.setImageResource(R.drawable.ic_draft);
+ emptyStateActionButton.setVisibility(VISIBLE);
+ emptyStateActionButton.setText(R.string.compose_new_mail);
+ break;
+
+ case "star":
+ emptyStateTitle.setText(R.string.empty_starred_title);
+ emptyStateSubtitle.setText(R.string.empty_starred_subtitle);
+ emptyStateIcon.setImageResource(R.drawable.ic_star_outline);
+ emptyStateActionButton.setVisibility(GONE);
+ break;
+
+ case "trash":
+ emptyStateTitle.setText(R.string.empty_trash_title);
+ emptyStateSubtitle.setText(R.string.empty_trash_subtitle);
+ emptyStateIcon.setImageResource(R.drawable.ic_delete);
+ emptyStateActionButton.setVisibility(GONE);
+ break;
+
+ case "spam":
+ emptyStateTitle.setText(R.string.empty_spam_title);
+ emptyStateSubtitle.setText(R.string.empty_spam_subtitle);
+ emptyStateIcon.setImageResource(R.drawable.ic_report);
+ emptyStateActionButton.setVisibility(GONE);
+ break;
+
+ case "all":
+ emptyStateTitle.setText(R.string.empty_all_title);
+ emptyStateSubtitle.setText(R.string.empty_all_subtitle);
+ emptyStateIcon.setImageResource(R.drawable.ic_mail);
+ emptyStateActionButton.setVisibility(VISIBLE);
+ emptyStateActionButton.setText(R.string.compose_new_mail);
+ break;
+
+ default:
+ if (inboxType.startsWith("label:")) {
+ emptyStateTitle.setText(R.string.empty_label_title);
+ emptyStateSubtitle.setText(R.string.empty_label_subtitle);
+ emptyStateIcon.setImageResource(R.drawable.ic_label);
+ emptyStateActionButton.setVisibility(GONE);
+ } else {
+ emptyStateTitle.setText(R.string.empty_inbox_title);
+ emptyStateSubtitle.setText(R.string.empty_inbox_subtitle);
+ emptyStateIcon.setImageResource(R.drawable.ic_mail);
+ emptyStateActionButton.setVisibility(VISIBLE);
+ emptyStateActionButton.setText(R.string.compose_new_mail);
+ }
+ break;
+ }
+ }
+
+ /**
+ * Toggles between an empty inbox state view and the regular
+ *
+ * @param isEmpty true if should show the empty inbox UI, otherwise will show the inbox
+ */
+ private void toggleEmptyState(boolean isEmpty) {
+ if (emptyStateContainer != null) {
+ emptyStateContainer.setVisibility(isEmpty ? VISIBLE : GONE);
+ updateEmptyStateContent(inboxType);
+ }
+ }
+
+ private void showSnack(@androidx.annotation.StringRes int resId) {
+ if (!isAdded()) return; // fragment not attached
+ View root = getView();
+ if (root != null) {
+ Snackbar.make(root, resId, Snackbar.LENGTH_SHORT).show();
+ } else {
+ // Fallback if view is gone (e.g., after rotation/detach)
+ Toast.makeText(requireContext(), resId, Toast.LENGTH_SHORT).show();
+ }
+ }
+
+ /**
+ * Observer for mail data changes
+ */
+ private void observeViewModel(SwipeRefreshLayout swipeRefreshLayout) {
+ // mails load observer
+ mailViewModel.getMailsLiveData().observe(getViewLifecycleOwner(), result -> {
+ if (result instanceof Result.Loading) {
+ Log.i("loadMails", inboxType);
+ if (!isLoading) {
+ swipeRefreshLayout.setRefreshing(true);
+ toggleEmptyState(false);
+ }
+ } else if (result instanceof Result.Success) {
+ MailListResponse response = ((Result.Success) result).getData();
+ List newMails = response.getMails();
+ // stop loading state
+ isLoading = false;
+ mailViewModel.setLoadingMore(false);
+ swipeRefreshLayout.setRefreshing(false);
+
+ if (mailViewModel.getCurrentPage() == 1) {
+ mailAdapter.setMailList(newMails);
+ // Show empty state if no mails
+ boolean isEmpty = newMails == null || newMails.isEmpty();
+ toggleEmptyState(isEmpty);
+ } else {
+ // Subsequent pages - append mails
+ if (newMails != null && !newMails.isEmpty()) {
+ mailAdapter.appendMails(newMails);
+ hasMoreData = newMails.size() >= PAGE_LIMIT; // Assume no more data if less than limit
+ } else {
+ hasMoreData = false; // No more data available
+ }
+ }
+
+ } else if (result instanceof Result.Error) {
+ isLoading = false;
+ mailViewModel.setLoadingMore(false);
+ swipeRefreshLayout.setRefreshing(false);
+
+ String msg = ((Result.Error>) result).getMessage();
+ Log.i("err", msg);
+ Toast.makeText(getContext(), getResources().getString(R.string.err_mails_load) + msg, Toast.LENGTH_LONG).show();
+ // Don't show empty state on error, just leave current state
+
+ // If it was a pagination request that failed, decrement page
+ if (mailViewModel.getCurrentPage() > 1)
+ mailViewModel.previousPage(inboxType);
+ }
+ });
+
+ // edit observer
+ mailViewModel.getEditMailStatus().observe(getViewLifecycleOwner(), result -> {
+ if (result instanceof Result.Success) {
+ mailAdapter.clearSelection();
+ mailViewModel.loadMails(inboxType);
+ } else if (result instanceof Result.Error) {
+ Toast.makeText(getContext(), R.string.unexpected_error, Toast.LENGTH_SHORT).show();
+ }
+ });
+
+ // search observer
+ mailViewModel.getSearchData().observe(getViewLifecycleOwner(), result -> {
+ if (result instanceof Result.Loading) {
+ swipeRefreshLayout.setRefreshing(true);
+ toggleEmptyState(false);
+ } else if (result instanceof Result.Success) {
+ List mails = ((Result.Success