Developing Offline-First Apps Using XML UI

In today’s world, users expect mobile applications to work seamlessly, regardless of their internet connectivity. Developing offline-first applications is essential to provide a smooth and reliable user experience. While many modern approaches utilize technologies like Jetpack Compose or Flutter, there’s still a significant need to support offline capabilities in applications that rely on XML-based UIs. This post explores the best practices for developing offline-first apps using XML UI, providing detailed code examples and strategies.

Understanding Offline-First Development

Offline-first development prioritizes making an application functional even when the device is not connected to the internet. This approach typically involves:

  • Storing data locally using databases or other storage mechanisms.
  • Syncing data between the local storage and remote servers when the connection is available.
  • Handling conflicts that may arise during data synchronization.

Why Offline-First?

Offline-first development provides numerous benefits:

  • Improved User Experience: Users can continue to use the app even without an internet connection.
  • Faster Load Times: Local data retrieval is generally faster than fetching data from remote servers.
  • Resilience: The application remains functional in areas with poor or intermittent connectivity.

Strategies for Offline-First Apps with XML UI

Developing offline-first apps with XML UI requires careful planning and execution. Here are some effective strategies:

1. Local Data Storage with SQLite

SQLite is a popular choice for local data storage on Android devices. It is lightweight, reliable, and requires no external server.

Step 1: Create a Database Helper Class

Create a class that extends SQLiteOpenHelper to manage database creation and upgrades.


import android.content.Context;
import android.database.sqlite.SQLiteDatabase;
import android.database.sqlite.SQLiteOpenHelper;

public class AppDatabaseHelper extends SQLiteOpenHelper {

    private static final String DATABASE_NAME = "myapp.db";
    private static final int DATABASE_VERSION = 1;

    public static final String TABLE_TASKS = "tasks";
    public static final String COLUMN_ID = "_id";
    public static final String COLUMN_TITLE = "title";
    public static final String COLUMN_DESCRIPTION = "description";
    public static final String COLUMN_COMPLETED = "completed";

    private static final String CREATE_TABLE_TASKS =
            "CREATE TABLE " + TABLE_TASKS + "("
            + COLUMN_ID + " INTEGER PRIMARY KEY AUTOINCREMENT,"
            + COLUMN_TITLE + " TEXT,"
            + COLUMN_DESCRIPTION + " TEXT,"
            + COLUMN_COMPLETED + " INTEGER" + ")";

    public AppDatabaseHelper(Context context) {
        super(context, DATABASE_NAME, null, DATABASE_VERSION);
    }

    @Override
    public void onCreate(SQLiteDatabase db) {
        db.execSQL(CREATE_TABLE_TASKS);
    }

    @Override
    public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) {
        db.execSQL("DROP TABLE IF EXISTS " + TABLE_TASKS);
        onCreate(db);
    }
}
Step 2: Define Data Access Objects (DAOs)

Create DAOs to handle database interactions, such as creating, reading, updating, and deleting records.


import android.content.ContentValues;
import android.content.Context;
import android.database.Cursor;
import android.database.sqlite.SQLiteDatabase;

import java.util.ArrayList;
import java.util.List;

public class TaskDao {

    private AppDatabaseHelper dbHelper;

    public TaskDao(Context context) {
        dbHelper = new AppDatabaseHelper(context);
    }

    public long createTask(Task task) {
        SQLiteDatabase db = dbHelper.getWritableDatabase();
        ContentValues values = new ContentValues();
        values.put(AppDatabaseHelper.COLUMN_TITLE, task.getTitle());
        values.put(AppDatabaseHelper.COLUMN_DESCRIPTION, task.getDescription());
        values.put(AppDatabaseHelper.COLUMN_COMPLETED, task.isCompleted() ? 1 : 0);
        long id = db.insert(AppDatabaseHelper.TABLE_TASKS, null, values);
        db.close();
        return id;
    }

    public Task getTask(long id) {
        SQLiteDatabase db = dbHelper.getReadableDatabase();
        Cursor cursor = db.query(
                AppDatabaseHelper.TABLE_TASKS,
                new String[]{AppDatabaseHelper.COLUMN_ID, AppDatabaseHelper.COLUMN_TITLE,
                        AppDatabaseHelper.COLUMN_DESCRIPTION, AppDatabaseHelper.COLUMN_COMPLETED},
                AppDatabaseHelper.COLUMN_ID + "=?",
                new String[]{String.valueOf(id)}, null, null, null, null);
        if (cursor != null && cursor.moveToFirst()) {
            Task task = new Task(
                    cursor.getLong(0),
                    cursor.getString(1),
                    cursor.getString(2),
                    cursor.getInt(3) > 0
            );
            cursor.close();
            db.close();
            return task;
        }
        db.close();
        return null;
    }

    public List<Task> getAllTasks() {
        List<Task> tasks = new ArrayList<>();
        SQLiteDatabase db = dbHelper.getReadableDatabase();
        Cursor cursor = db.query(AppDatabaseHelper.TABLE_TASKS,
                new String[]{AppDatabaseHelper.COLUMN_ID, AppDatabaseHelper.COLUMN_TITLE,
                        AppDatabaseHelper.COLUMN_DESCRIPTION, AppDatabaseHelper.COLUMN_COMPLETED},
                null, null, null, null, null);

        if (cursor.moveToFirst()) {
            do {
                Task task = new Task(
                        cursor.getLong(0),
                        cursor.getString(1),
                        cursor.getString(2),
                        cursor.getInt(3) > 0
                );
                tasks.add(task);
            } while (cursor.moveToNext());
        }
        cursor.close();
        db.close();
        return tasks;
    }

    public int updateTask(Task task) {
        SQLiteDatabase db = dbHelper.getWritableDatabase();
        ContentValues values = new ContentValues();
        values.put(AppDatabaseHelper.COLUMN_TITLE, task.getTitle());
        values.put(AppDatabaseHelper.COLUMN_DESCRIPTION, task.getDescription());
        values.put(AppDatabaseHelper.COLUMN_COMPLETED, task.isCompleted() ? 1 : 0);
        int rowsAffected = db.update(AppDatabaseHelper.TABLE_TASKS, values,
                AppDatabaseHelper.COLUMN_ID + "=?",
                new String[]{String.valueOf(task.getId())});
        db.close();
        return rowsAffected;
    }

    public int deleteTask(long id) {
        SQLiteDatabase db = dbHelper.getWritableDatabase();
        int rowsAffected = db.delete(AppDatabaseHelper.TABLE_TASKS,
                AppDatabaseHelper.COLUMN_ID + "=?",
                new String[]{String.valueOf(id)});
        db.close();
        return rowsAffected;
    }
}
Step 3: Create the Task Model Class

public class Task {
    private long id;
    private String title;
    private String description;
    private boolean completed;

    public Task(long id, String title, String description, boolean completed) {
        this.id = id;
        this.title = title;
        this.description = description;
        this.completed = completed;
    }

    // Getters and setters
    public long getId() {
        return id;
    }

    public void setId(long id) {
        this.id = id;
    }

    public String getTitle() {
        return title;
    }

    public void setTitle(String title) {
        this.title = title;
    }

    public String getDescription() {
        return description;
    }

    public void setDescription(String description) {
        this.description = description;
    }

    public boolean isCompleted() {
        return completed;
    }

    public void setCompleted(boolean completed) {
        this.completed = completed;
    }
}
Step 4: Using DAOs in Activities

Utilize the DAOs in your activities to perform database operations. For example, to display tasks in a ListView:


import android.os.Bundle;
import android.widget.ArrayAdapter;
import android.widget.ListView;

import androidx.appcompat.app.AppCompatActivity;

import java.util.List;

public class MainActivity extends AppCompatActivity {

    private ListView taskListView;
    private ArrayAdapter<String> taskAdapter;
    private TaskDao taskDao;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);

        taskListView = findViewById(R.id.taskListView);
        taskDao = new TaskDao(this);

        loadTasks();
    }

    private void loadTasks() {
        List<Task> tasks = taskDao.getAllTasks();
        String[] taskTitles = new String[tasks.size()];
        for (int i = 0; i < tasks.size(); i++) {
            taskTitles[i] = tasks.get(i).getTitle();
        }

        taskAdapter = new ArrayAdapter<>(this,
                android.R.layout.simple_list_item_1, taskTitles);
        taskListView.setAdapter(taskAdapter);
    }
}

2. Caching Remote Data

Cache data retrieved from remote servers to provide an offline experience. Use shared preferences, internal storage, or external storage for caching.

Step 1: Fetch Data from the Network

Use HttpURLConnection, Retrofit, or Volley to fetch data from remote APIs.


import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.net.HttpURLConnection;
import java.net.URL;

public class NetworkUtils {

    public static String fetchData(String urlString) throws IOException {
        URL url = new URL(urlString);
        HttpURLConnection connection = (HttpURLConnection) url.openConnection();
        connection.setRequestMethod("GET");

        try {
            InputStream inputStream = connection.getInputStream();
            BufferedReader reader = new BufferedReader(new InputStreamReader(inputStream));
            StringBuilder stringBuilder = new StringBuilder();
            String line;

            while ((line = reader.readLine()) != null) {
                stringBuilder.append(line);
            }

            return stringBuilder.toString();
        } finally {
            connection.disconnect();
        }
    }
}
Step 2: Cache the Data

Store the fetched data locally. Using SharedPreferences for simple data caching.


import android.content.Context;
import android.content.SharedPreferences;

public class CacheManager {

    private static final String PREF_NAME = "AppDataCache";
    private static final String TASKS_DATA_KEY = "tasksData";

    public static void cacheTasksData(Context context, String tasksData) {
        SharedPreferences prefs = context.getSharedPreferences(PREF_NAME, Context.MODE_PRIVATE);
        SharedPreferences.Editor editor = prefs.edit();
        editor.putString(TASKS_DATA_KEY, tasksData);
        editor.apply();
    }

    public static String getCachedTasksData(Context context) {
        SharedPreferences prefs = context.getSharedPreferences(PREF_NAME, Context.MODE_PRIVATE);
        return prefs.getString(TASKS_DATA_KEY, null);
    }
}
Step 3: Load Data from Cache if Offline

import android.content.Context;
import android.net.ConnectivityManager;
import android.net.NetworkInfo;
import android.os.AsyncTask;
import android.os.Bundle;
import android.widget.TextView;
import androidx.appcompat.app.AppCompatActivity;
import org.json.JSONArray;
import org.json.JSONException;

import java.io.IOException;

public class DataActivity extends AppCompatActivity {

    private TextView dataTextView;
    private static final String TASKS_URL = "https://example.com/api/tasks";

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_data);

        dataTextView = findViewById(R.id.dataTextView);

        loadData();
    }

    private void loadData() {
        if (isNetworkAvailable()) {
            new FetchDataTask().execute(TASKS_URL);
        } else {
            String cachedData = CacheManager.getCachedTasksData(this);
            if (cachedData != null) {
                displayData(cachedData);
            } else {
                dataTextView.setText("No data available. Connect to the internet and try again.");
            }
        }
    }

    private boolean isNetworkAvailable() {
        ConnectivityManager connectivityManager =
                (ConnectivityManager) getSystemService(Context.CONNECTIVITY_SERVICE);
        NetworkInfo activeNetworkInfo = connectivityManager.getActiveNetworkInfo();
        return activeNetworkInfo != null && activeNetworkInfo.isConnected();
    }

    private void displayData(String data) {
        try {
            JSONArray jsonArray = new JSONArray(data);
            StringBuilder stringBuilder = new StringBuilder();
            for (int i = 0; i < jsonArray.length(); i++) {
                stringBuilder.append(jsonArray.getJSONObject(i).getString("title")).append("\n");
            }
            dataTextView.setText(stringBuilder.toString());
        } catch (JSONException e) {
            dataTextView.setText("Error parsing data.");
            e.printStackTrace();
        }
    }

    private class FetchDataTask extends AsyncTask<String, Void, String> {
        @Override
        protected String doInBackground(String... urls) {
            try {
                return NetworkUtils.fetchData(urls[0]);
            } catch (IOException e) {
                return null;
            }
        }

        @Override
        protected void onPostExecute(String result) {
            if (result != null) {
                CacheManager.cacheTasksData(DataActivity.this, result);
                displayData(result);
            } else {
                dataTextView.setText("Failed to fetch data. Please try again.");
            }
        }
    }
}

3. Background Sync with SyncAdapter

Use SyncAdapter to periodically synchronize data between the local storage and remote server in the background.

Step 1: Create a SyncAdapter

import android.accounts.Account;
import android.content.AbstractThreadedSyncAdapter;
import android.content.ContentProviderClient;
import android.content.Context;
import android.content.SyncResult;
import android.os.Bundle;
import android.util.Log;

import java.io.IOException;

public class AppSyncAdapter extends AbstractThreadedSyncAdapter {

    private static final String TAG = "AppSyncAdapter";
    private final Context context;

    public AppSyncAdapter(Context context, boolean autoInitialize) {
        super(context, autoInitialize);
        this.context = context;
    }

    @Override
    public void onPerformSync(Account account, Bundle extras, String authority,
                              ContentProviderClient provider, SyncResult syncResult) {
        Log.i(TAG, "Starting sync");

        try {
            // Fetch data from network
            String data = NetworkUtils.fetchData("https://example.com/api/tasks");

            if (data != null) {
                // Cache the fetched data
                CacheManager.cacheTasksData(context, data);
                Log.i(TAG, "Sync successful");
            } else {
                syncResult.stats.numIoExceptions++;
                Log.e(TAG, "Sync failed: Unable to fetch data");
            }
        } catch (IOException e) {
            syncResult.stats.numIoExceptions++;
            Log.e(TAG, "Sync error: " + e.getMessage());
        }
    }
}
Step 2: Create a Sync Service

import android.app.Service;
import android.content.Intent;
import android.os.IBinder;

public class AppSyncService extends Service {

    private static AppSyncAdapter syncAdapter = null;
    private static final Object lock = new Object();

    @Override
    public void onCreate() {
        synchronized (lock) {
            if (syncAdapter == null) {
                syncAdapter = new AppSyncAdapter(getApplicationContext(), true);
            }
        }
    }

    @Override
    public IBinder onBind(Intent intent) {
        return syncAdapter.getSyncAdapterBinder();
    }
}
Step 3: Create an Authenticator

import android.accounts.AbstractAccountAuthenticator;
import android.accounts.Account;
import android.accounts.AccountAuthenticatorResponse;
import android.accounts.NetworkErrorException;
import android.content.Context;
import android.os.Bundle;

public class AppAuthenticator extends AbstractAccountAuthenticator {

    public AppAuthenticator(Context context) {
        super(context);
    }

    @Override
    public Bundle editProperties(AccountAuthenticatorResponse response, String accountType) {
        return null;
    }

    @Override
    public Bundle addAccount(AccountAuthenticatorResponse response, String accountType, String authTokenType, String[] requiredFeatures, Bundle options) throws NetworkErrorException {
        return null;
    }

    @Override
    public Bundle confirmCredentials(AccountAuthenticatorResponse response, Account account, Bundle options) throws NetworkErrorException {
        return null;
    }

    @Override
    public Bundle getAuthToken(AccountAuthenticatorResponse response, Account account, String authTokenType, Bundle options) throws NetworkErrorException {
        throw new UnsupportedOperationException();
    }

    @Override
    public String getAuthTokenLabel(String authTokenType) {
        return null;
    }

    @Override
    public Bundle updateCredentials(AccountAuthenticatorResponse response, Account account, String authTokenType, Bundle options) throws NetworkErrorException {
        throw new UnsupportedOperationException();
    }

    @Override
    public Bundle hasFeatures(AccountAuthenticatorResponse response, Account account, String[] features) throws NetworkErrorException {
        return null;
    }
}
Step 4: Provide an Authenticator Service

import android.app.Service;
import android.content.Intent;
import android.os.IBinder;

public class AppAuthenticatorService extends Service {

    private AppAuthenticator authenticator;

    @Override
    public void onCreate() {
        authenticator = new AppAuthenticator(this);
    }

    @Override
    public IBinder onBind(Intent intent) {
        return authenticator.getIBinder();
    }
}
Step 5: Configure the SyncAdapter

Set up the account and trigger synchronization. In MainActivity:


import android.accounts.Account;
import android.accounts.AccountManager;
import android.content.ContentResolver;
import android.os.Bundle;
import androidx.appcompat.app.AppCompatActivity;

public class MainActivity extends AppCompatActivity {

    private static final String ACCOUNT = "syncAccount";
    private static final String ACCOUNT_TYPE = "com.example.myapp";
    private static final String AUTHORITY = "com.example.myapp.provider";
    private static final long SYNC_INTERVAL = 60 * 60; // 1 hour

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);

        Account account = CreateSyncAccount(this);

        // Enable periodic syncing
        ContentResolver.addPeriodicSync(
                account,
                AUTHORITY,
                Bundle.EMPTY,
                SYNC_INTERVAL);

        // Request an immediate sync
        Bundle settingsBundle = new Bundle();
        settingsBundle.putBoolean(ContentResolver.SYNC_EXTRAS_MANUAL, true);
        settingsBundle.putBoolean(ContentResolver.SYNC_EXTRAS_EXPEDITED, true);
        ContentResolver.requestSync(account, AUTHORITY, settingsBundle);
    }

    private Account CreateSyncAccount(Context context) {
        Account newAccount = new Account(ACCOUNT, ACCOUNT_TYPE);
        AccountManager accountManager = (AccountManager) context.getSystemService(Context.ACCOUNT_SERVICE);
        if (accountManager.addAccountExplicitly(newAccount, null, null)) {
            return newAccount;
        } else {
            return accountManager.getAccountsByType(ACCOUNT_TYPE)[0];
        }
    }
}

4. Handling Conflicts

Conflicts may arise when the same data is modified both locally and remotely. Implement conflict resolution strategies like:

  • Last Write Wins: The latest modification overwrites the older one.
  • Merge Changes: Attempt to merge both changes.
  • User Resolution: Prompt the user to choose which change to keep.

Conflict resolution can be complex and depends heavily on the application’s specific needs and data model.

XML UI Considerations

When developing offline-first apps with XML UI, keep the following considerations in mind:

  • Data Binding: Utilize data binding to efficiently display local data in UI elements.
  • ListViews and RecyclerViews: Efficiently display lists of data with adapters that support asynchronous loading.
  • Error Handling: Provide informative error messages when the application is offline and data is unavailable.

Conclusion

Developing offline-first apps with XML UI requires careful architecture and the use of local storage mechanisms like SQLite, combined with strategies for caching remote data and synchronizing changes. By employing SyncAdapter and thoughtfully handling potential conflicts, you can create robust applications that provide a seamless experience even when offline. Remember to prioritize the user experience by providing clear feedback on data availability and synchronization status. Properly implementing these techniques ensures your app remains functional and responsive regardless of the network connectivity.