Skip to main content

Overview

Threadly implements comprehensive offline support using Room Database, an abstraction layer over SQLite. This enables the app to function seamlessly even without network connectivity, with automatic synchronization when the connection is restored.

Architecture

Offline-First Strategy

┌─────────────────────────────────────────┐
│           User Action                   │
└───────────────┬─────────────────────────┘


┌─────────────────────────────────────────┐
│     1. Save to Room Database            │
│        (Immediate, always works)        │
└───────────────┬─────────────────────────┘


┌─────────────────────────────────────────┐
│     2. Attempt Network Sync             │
│        (Background, when available)     │
└───────────────┬─────────────────────────┘

        ┌───────┴────────┐
        ▼                ▼
    Success          Failure
        │                │
        ▼                ▼
  Update Status    Retry Later

Room Database Setup

Database Configuration

@Database(
    entities = {
        MessageSchema.class, 
        HistorySchema.class, 
        NotificationSchema.class
    },
    version = 1,
    exportSchema = false
)
public abstract class DataBase extends RoomDatabase {
    public static final String DB_NAME = "Threadly";
    public static DataBase instance;
    
    public static synchronized DataBase getInstance(){
        if (instance == null){
            instance = Room.databaseBuilder(
                Threadly.getGlobalContext(),
                DataBase.class,
                DB_NAME
            ).build();
        }
        return instance;
    }
    
    // Data Access Objects
    public abstract operator MessageDao();
    public abstract HistoryOperator historyOperator();
    public abstract NotificationDao notificationDao();
}
Location: RoomDb/DataBase.java:1

Key Features

FeatureImplementation
Singleton PatternSingle database instance app-wide
Thread-SafeSynchronized getInstance() method
Multi-EntityMessages, history, and notifications
Background OperationsAll DB operations run off main thread

Database Entities

MessageSchema

Stores all chat messages with delivery status and metadata:
@Entity(tableName = "messages")
public class MessageSchema {
    @PrimaryKey(autoGenerate = true)
    private long msgId;
    
    @ColumnInfo(name = "messageUid")
    private String messageUid;
    
    @ColumnInfo(name = "conversationId")
    private String conversationId;
    
    @ColumnInfo(name = "replyToMsgId")
    private String replyToMsgId;
    
    @ColumnInfo(name = "senderId")
    private String senderId;
    
    @ColumnInfo(name = "receiverId")
    private String receiverId;
    
    @ColumnInfo(name = "msg")
    private String msg;
    
    @ColumnInfo(name = "type")
    private String type;  // text, image, video, etc.
    
    @ColumnInfo(name = "postId")
    private int postId;
    
    @ColumnInfo(name = "postLink")
    private String postLink;
    
    @ColumnInfo(name = "timestamp")
    private String timestamp;
    
    @ColumnInfo(name = "deliveryStatus")
    private int deliveryStatus;  // 0=pending, 1=sent, -1=delivered, -2=seen
    
    @ColumnInfo(name = "isDeleted")
    private boolean isDeleted;
    
    @ColumnInfo(name = "mediaLocalPath")
    private String mediaLocalPath;  // Local file path for offline access
    
    @ColumnInfo(name = "mediaUploadState")
    private String mediaUploadState;  // pending, uploading, uploaded, failed
    
    @ColumnInfo(name = "totalSize")
    private long totalSize;
    
    @ColumnInfo(name = "uploadedSize")
    private long uploadedSize;  // For upload progress tracking
    
    // Constructors, getters, and setters...
}
Location: RoomDb/schemas/MessageSchema.java:1

Message Status Flow

StatusCodeMeaningUI Indicator
Pending0Saved locally, awaiting sendClock icon
Sent1Sent to serverSingle check
Delivered-1Received by recipient’s deviceDouble check
Seen-2Read by recipientBlue double check

Data Access Objects (DAOs)

Message DAO Operations

@Dao
public interface operator {
    // Insert operations
    @Insert
    void insertMessage(MessageSchema message);
    
    @Insert
    void insertMessage(List<MessageSchema> messages);
    
    // Query messages for a conversation
    @Query("SELECT * FROM messages " +
           "WHERE conversationId=:conversationId AND isDeleted=0 " +
           "GROUP BY messageUid " +
           "ORDER BY timestamp ASC")
    LiveData<List<MessageSchema>> getMessagesCid(String conversationId);
    
    // Update delivery status
    @Query("UPDATE messages " +
           "SET deliveryStatus=:deliveryStatus " +
           "WHERE messageUid=:msgUid AND isDeleted=0")
    void updateDeliveryStatus(String msgUid, int deliveryStatus);
    
    // Get pending messages (for retry mechanism)
    @Query("SELECT * FROM messages " +
           "WHERE deliveryStatus=0 AND isDeleted=0")
    List<MessageSchema> getPendingToSendMessages();
    
    // Unread message counts
    @Query("SELECT COUNT(DISTINCT messageUid) as count " +
           "FROM messages " +
           "WHERE deliveryStatus=-1 AND receiverId=:rid AND isDeleted=0")
    LiveData<Integer> getUnreadMessagesCount(String rid);
    
    @Query("SELECT COUNT(DISTINCT conversationId) as count " +
           "FROM messages " +
           "WHERE deliveryStatus=-1 AND receiverId=:rid AND isDeleted=0")
    LiveData<Integer> getUnreadConversationCount(String rid);
    
    // Mark messages as seen
    @Query("UPDATE messages " +
           "SET deliveryStatus=-2 " +
           "WHERE conversationId=:conversationId " +
           "AND receiverId=:rid AND isDeleted=0")
    void updateMessagesSeen(String conversationId, String rid);
    
    // Soft delete
    @Query("UPDATE messages SET isDeleted=1 " +
           "WHERE messageUid=:msgUid AND isDeleted=0")
    void deleteMessage(String msgUid);
    
    // Media upload progress tracking
    @Query("UPDATE messages " +
           "SET totalSize=:totalSize, uploadedSize=:uploadedSize " +
           "WHERE messageUid=:messageUid AND isDeleted=0")
    void updateUploadProgress(String messageUid, long totalSize, long uploadedSize);
    
    // Get messages with failed uploads
    @Query("SELECT * FROM messages " +
           "WHERE mediaUploadState=:state1 AND isDeleted=0 " +
           "ORDER BY timestamp DESC")
    List<MessageSchema> getAllUnUploadedMessages(String state1);
    
    // Update media upload state
    @Query("UPDATE messages " +
           "SET postLink=:link, mediaUploadState=:mediaUploadState " +
           "WHERE messageUid=:messageUid AND isDeleted=0")
    void updatePostLinkWithState(String messageUid, String link, String mediaUploadState);
}
Location: RoomDb/Dao/operator.java:1

Offline Workflows

Sending Messages Offline

// 1. Create message with pending status
String messageUid = UUID.randomUUID().toString();
String timestamp = ISO8601Util.getCurrentTimestamp();

MessageSchema message = new MessageSchema(
    messageUid,
    conversationId,
    null,  // replyToMsgId
    senderId,
    receiverId,
    messageText,
    "text",
    0,     // postId
    null,  // postLink
    timestamp,
    0,     // deliveryStatus: 0 = pending
    false  // isDeleted
);

// 2. Save to local database immediately (works offline)
Executors.newSingleThreadExecutor().execute(() -> {
    DataBase.getInstance().MessageDao().insertMessage(message);
});

// 3. Display message in UI immediately
runOnUiThread(() -> {
    chatAdapter.addMessage(message);
});

// 4. Attempt to send to server (background)
if (isNetworkAvailable()) {
    MessageManager.sendMessage(payload);
} else {
    // Will retry when network is restored
    scheduleMessageRetry(messageUid);
}

Retrying Failed Messages

public void retryPendingMessages() {
    Executors.newSingleThreadExecutor().execute(() -> {
        // Get all messages with pending status
        List<MessageSchema> pendingMessages = 
            DataBase.getInstance()
                   .MessageDao()
                   .getPendingToSendMessages();
        
        for (MessageSchema message : pendingMessages) {
            // Build JSON payload
            JSONObject payload = new JSONObject();
            payload.put("messageUid", message.getMessageUid());
            payload.put("message", message.getMsg());
            payload.put("receiverId", message.getReceiverId());
            payload.put("type", message.getType());
            
            // Retry sending
            MessageManager.sendMessage(payload);
        }
    });
}

// Call this when network is restored
private void onNetworkRestored() {
    retryPendingMessages();
    checkAndGetPendingMessages();  // Fetch messages received while offline
}

Loading Messages from Cache

public void loadConversation(String conversationId) {
    // Observe messages from local database
    LiveData<List<MessageSchema>> messagesLiveData = 
        DataBase.getInstance()
               .MessageDao()
               .getMessagesCid(conversationId);
    
    messagesLiveData.observe(this, messages -> {
        // Update UI automatically when database changes
        chatAdapter.setMessages(messages);
        
        // Scroll to bottom
        recyclerView.scrollToPosition(messages.size() - 1);
    });
}

Media Caching

Offline Media Access

// Save media file locally and track in database
public void sendImageMessage(File imageFile, String caption) {
    String messageUid = UUID.randomUUID().toString();
    String localPath = imageFile.getAbsolutePath();
    
    // Create message with local file path
    MessageSchema message = new MessageSchema(
        messageUid,
        conversationId,
        null,
        senderId,
        receiverId,
        caption,
        "image",
        0,
        null,
        timestamp,
        0,  // pending
        false,
        localPath,  // mediaLocalPath - for offline viewing
        "pending",  // mediaUploadState
        imageFile.length(),  // totalSize
        0  // uploadedSize
    );
    
    // Save to database
    DataBase.getInstance().MessageDao().insertMessage(message);
    
    // Upload in background
    uploadMediaWithProgress(messageUid, imageFile);
}

Upload Progress Tracking

private void uploadMediaWithProgress(String messageUid, File mediaFile) {
    MessageManager.UploadMsgMedia(
        mediaFile,
        messageUid,  // Tag for cancellation
        new NetworkCallbackInterfaceWithProgressTracking() {
            @Override
            public void progress(long bytesUploaded, long totalBytes) {
                // Update database with progress
                DataBase.getInstance()
                       .MessageDao()
                       .updateUploadProgress(messageUid, totalBytes, bytesUploaded);
                
                // Update UI
                int percentage = (int) ((bytesUploaded * 100) / totalBytes);
                updateProgressUI(messageUid, percentage);
            }
            
            @Override
            public void onSuccess(JSONObject response) {
                String mediaUrl = response.optString("url");
                
                // Update with server URL
                DataBase.getInstance()
                       .MessageDao()
                       .updatePostLinkWithState(messageUid, mediaUrl, "uploaded");
            }
            
            @Override
            public void onError(String error) {
                // Mark as failed for later retry
                DataBase.getInstance()
                       .MessageDao()
                       .updateUploadState(messageUid, "failed");
            }
        }
    );
}
Location: network_managers/MessageManager.java:198

Retry Failed Uploads

public void retryFailedUploads() {
    Executors.newSingleThreadExecutor().execute(() -> {
        List<MessageSchema> failedMessages = 
            DataBase.getInstance()
                   .MessageDao()
                   .getAllUnUploadedMessages("failed");
        
        for (MessageSchema message : failedMessages) {
            File mediaFile = new File(message.getMediaLocalPath());
            
            if (mediaFile.exists()) {
                // Mark as pending and retry
                DataBase.getInstance()
                       .MessageDao()
                       .updateUploadState(message.getMessageUid(), "pending");
                
                uploadMediaWithProgress(message.getMessageUid(), mediaFile);
            } else {
                // Local file deleted, remove from database
                DataBase.getInstance()
                       .MessageDao()
                       .deleteMessage(message.getMessageUid());
            }
        }
    });
}

LiveData Observation

Real-Time UI Updates

Room’s LiveData integration provides automatic UI updates when data changes:
public class ChatActivity extends AppCompatActivity {
    private LiveData<List<MessageSchema>> messagesLiveData;
    private LiveData<Integer> unreadCountLiveData;
    
    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        
        // Observe messages
        messagesLiveData = DataBase.getInstance()
            .MessageDao()
            .getMessagesCid(conversationId);
        
        messagesLiveData.observe(this, messages -> {
            // Automatically called when messages change
            chatAdapter.updateMessages(messages);
        });
        
        // Observe unread count
        unreadCountLiveData = DataBase.getInstance()
            .MessageDao()
            .getUnreadMessagesCount(myUserId);
        
        unreadCountLiveData.observe(this, count -> {
            // Update badge
            updateUnreadBadge(count);
        });
    }
}

Background Sync with WorkManager

Periodic Sync Task

public class MessageSyncWorker extends Worker {
    public MessageSyncWorker(Context context, WorkerParameters params) {
        super(context, params);
    }
    
    @Override
    public Result doWork() {
        try {
            // 1. Retry pending messages
            retryPendingMessages();
            
            // 2. Fetch new messages from server
            MessageManager.checkAndGetPendingMessages();
            
            // 3. Retry failed media uploads
            retryFailedUploads();
            
            return Result.success();
        } catch (Exception e) {
            return Result.retry();
        }
    }
}

// Schedule periodic sync
public void scheduleMessageSync() {
    PeriodicWorkRequest syncWorkRequest = 
        new PeriodicWorkRequest.Builder(
            MessageSyncWorker.class,
            15,  // Every 15 minutes
            TimeUnit.MINUTES
        )
        .setConstraints(
            new Constraints.Builder()
                .setRequiredNetworkType(NetworkType.CONNECTED)
                .build()
        )
        .build();
    
    WorkManager.getInstance(context)
        .enqueueUniquePeriodicWork(
            "message_sync",
            ExistingPeriodicWorkPolicy.KEEP,
            syncWorkRequest
        );
}

Dependency

# gradle/libs.versions.toml
roomRuntime = "2.6.1"
workRuntime = "2.10.2"

[libraries]
androidx-room-runtime = { 
    module = "androidx.room:room-runtime", 
    version.ref = "roomRuntime" 
}
androidx-room-compiler = { 
    module = "androidx.room:room-compiler", 
    version.ref = "roomRuntime" 
}
androidx-work-runtime = { 
    module = "androidx.work:work-runtime", 
    version.ref = "workRuntime" 
}
// app/build.gradle
implementation libs.androidx.room.runtime
annotationProcessor libs.androidx.room.compiler
implementation libs.androidx.work.runtime

Best Practices

1. Always Use Background Threads

// ✓ Good: Run DB operations off main thread
Executors.newSingleThreadExecutor().execute(() -> {
    DataBase.getInstance().MessageDao().insertMessage(message);
});

// ✗ Bad: DB operations on main thread (will crash)
DataBase.getInstance().MessageDao().insertMessage(message);

2. Use LiveData for Reactive UI

// ✓ Good: Observe LiveData for automatic updates
LiveData<List<MessageSchema>> messages = dao.getMessagesCid(conversationId);
messages.observe(this, list -> updateUI(list));

// ✗ Bad: Manual polling
while (true) {
    List<MessageSchema> messages = dao.getMessagesCid(conversationId);
    updateUI(messages);
    Thread.sleep(1000);
}

3. Implement Soft Deletes

// ✓ Good: Mark as deleted (can be synced to server)
@Query("UPDATE messages SET isDeleted=1 WHERE messageUid=:msgUid")
void deleteMessage(String msgUid);

// ✗ Bad: Hard delete (can't sync deletion to server)
@Delete
void deleteMessage(MessageSchema message);

4. Track Sync Status

// Always include status fields for offline sync
private int deliveryStatus;      // Track delivery state
private String mediaUploadState; // Track upload state
private boolean isDeleted;       // Track deletion state

Build docs developers (and LLMs) love