Store Data Offline in SQLite and Sync to Firebase in Android (2026)

Store Data Offline in SQLite and Sync to Firebase in Android (2026) | D.Tech Academic

Store Data Offline in SQLite and Sync to Firebase in Android (2026)

Last Updated: | Author: Deep Singh

Real-world apps can't assume the user always has internet. Flights, subways, rural areas — connectivity drops all the time. If your app just throws an error whenever the network is down, users will uninstall it.

The right approach is an offline-first architecture: save data locally first, then sync to the cloud when the internet comes back. In this tutorial, you'll build exactly that using SQLite for local storage and Firebase Realtime Database for cloud sync. A broadcast receiver watches for connectivity changes and automatically pushes any pending records to Firebase.

Output Screen

The app shows a text input where the user types a name and taps a button. If the device is online, the record goes straight to Firebase. If not, it's saved to SQLite and pushed to Firebase automatically once connectivity returns.

Android app with text input and button for offline-first data entry
The main screen with name input and store button
Android app showing synced student records from Firebase in a list
Records synced from Firebase displayed in a ListView

AndroidManifest.xml

The app needs network state permission to detect connectivity changes, and internet permission to talk to Firebase. The broadcast receiver is registered here so it can fire even when the app is in the background.

<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    package="com.example.internetchecking">

    <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
    <uses-permission android:name="android.permission.INTERNET" />

    <application
        android:allowBackup="true"
        android:icon="@mipmap/ic_launcher"
        android:label="@string/app_name"
        android:roundIcon="@mipmap/ic_launcher_round"
        android:supportsRtl="true"
        android:name=".SingleTask"
        android:theme="@style/AppTheme">

        <activity android:name=".MainActivity">
            <intent-filter>
                <action android:name="android.intent.action.MAIN" />
                <category android:name="android.intent.category.LAUNCHER" />
            </intent-filter>
        </activity>

        <receiver
            android:name=".InternetCheck"
            android:exported="false">
            <intent-filter>
                <action android:name="android.net.conn.CONNECTIVITY_CHANGE" />
            </intent-filter>
        </receiver>

    </application>

</manifest>

Fix from the original: The original used <uses-permission-sdk-23> which is wrong for these permissions. ACCESS_NETWORK_STATE and INTERNET are normal permissions that are granted at install time, so they use the plain <uses-permission> tag. Also added android:exported="false" on the receiver, which is required for Android 12+ (API 31+).

activity_main.xml

<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    android:layout_width="match_parent"
    android:layout_height="match_parent">

    <EditText
        android:id="@+id/tname"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:layout_marginLeft="20dp"
        android:layout_marginTop="80dp"
        android:layout_marginRight="20dp"
        android:hint="Enter Name"
        android:inputType="textPersonName"
        android:padding="20dp"
        app:layout_constraintTop_toTopOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintEnd_toEndOf="parent" />

    <Button
        android:id="@+id/button"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:layout_marginLeft="20dp"
        android:layout_marginTop="20dp"
        android:layout_marginRight="20dp"
        android:onClick="checkInternet"
        android:text="Store Data"
        app:layout_constraintTop_toBottomOf="@id/tname"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintEnd_toEndOf="parent" />

    <ListView
        android:id="@+id/mylist"
        android:layout_width="match_parent"
        android:layout_height="0dp"
        android:layout_marginLeft="20dp"
        android:layout_marginTop="20dp"
        android:layout_marginRight="20dp"
        android:layout_marginBottom="20dp"
        app:layout_constraintTop_toBottomOf="@id/button"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintEnd_toEndOf="parent" />

</androidx.constraintlayout.widget.ConstraintLayout>

What's fixed: The original layout used confusing constraint biases and had the button overlapping the input field. This version uses a straightforward vertical flow — input, button, list.

Student.java — The Data Model

package com.example.internetchecking;

public class Student {

    private int status;
    private String name;

    public int getStatus() { return status; }
    public void setStatus(int status) { this.status = status; }

    public String getName() { return name; }
    public void setName(String name) { this.name = name; }

    @Override
    public String toString() {
        return name + "\t\t\t" + (status == 1 ? "synced" : "pending");
    }
}

Note on null safety: The original had @NonNull on toString(), which is unusual for an override. Removed it. Also, the original just printed the raw integer status; this version shows "synced" or "pending" which is more readable.

SingleTask.java — Global Connectivity Flag

package com.example.internetchecking;

import android.app.Application;

public class SingleTask extends Application {

    /** Set by the broadcast receiver whenever connectivity changes. */
    public static boolean isOnline = false;
}

What's fixed: The original had an empty onCreate() override that just called super. That's a no-op — removed it. Also renamed the flag from status to isOnline, which is what it actually represents.

Database.java — SQLite Helper

package com.example.internetchecking;

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

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

public class Database extends SQLiteOpenHelper {

    private static final String DATABASE_NAME = "college.db";
    private static final String TABLE_NAME = "student";
    private static final String NAME = "name";
    private static final String STATUS = "status";
    private static final int DATABASE_VERSION = 1;

    private static final String CREATE_QUERY =
            "CREATE TABLE " + TABLE_NAME + " (" +
            NAME + " TEXT, " +
            STATUS + " INTEGER)";

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

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

    @Override
    public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) {
        db.execSQL("DROP TABLE IF EXISTS " + TABLE_NAME);
        onCreate(db);
    }

    /** Insert a pending student record. */
    public long insert(Student student) {
        ContentValues cv = new ContentValues();
        cv.put(NAME, student.getName());
        cv.put(STATUS, student.getStatus());
        return getWritableDatabase().insert(TABLE_NAME, null, cv);
    }

    /** Returns all records with status = 0 (pending sync). */
    public List<Student> getPendingStudents() {
        List<Student> students = new ArrayList<>();
        Cursor cursor = getReadableDatabase().query(
                TABLE_NAME,
                null,
                STATUS + " = ?",
                new String[]{String.valueOf(0)},
                null, null, null);

        while (cursor.moveToNext()) {
            Student s = new Student();
            s.setName(cursor.getString(cursor.getColumnIndexOrThrow(NAME)));
            s.setStatus(cursor.getInt(cursor.getColumnIndexOrThrow(STATUS)));
            students.add(s);
        }
        cursor.close();
        return students;
    }

    /** Marks a record as synced (status = 1). */
    public int markAsSynced(Student student) {
        ContentValues cv = new ContentValues();
        cv.put(STATUS, 1);
        return getWritableDatabase().update(
                TABLE_NAME,
                cv,
                NAME + " = ?",
                new String[]{student.getName()});
    }

    /** Deletes synced records to keep the local database clean. */
    public void deleteSyncedRecords() {
        getWritableDatabase().delete(TABLE_NAME, STATUS + " = ?",
                new String[]{"1"});
    }
}

What's fixed: Added DATABASE_VERSION as a named constant (the original had a magic 1). Used getColumnIndexOrThrow() instead of raw indices so a schema change won't silently break the query. Closed the cursor. Renamed getOfflineStudent() to getPendingStudents() and updateStatus() to markAsSynced(), which are clearer names.

InternetCheck.java — The BroadcastReceiver

package com.example.internetchecking;

import android.content.BroadcastReceiver;
import android.content.Context;
import android.content.Intent;
import android.net.ConnectivityManager;
import android.net.NetworkInfo;
import android.util.Log;
import android.widget.Toast;

import com.google.firebase.database.DatabaseReference;
import com.google.firebase.database.FirebaseDatabase;

import java.util.List;

public class InternetCheck extends BroadcastReceiver {

    private Database db;
    private DatabaseReference databaseReference;

    @Override
    public void onReceive(Context context, Intent intent) {
        db = new Database(context);
        databaseReference = FirebaseDatabase.getInstance().getReference("student");
        checkConnectivity(context);
    }

    private void checkConnectivity(Context context) {
        ConnectivityManager cm = (ConnectivityManager)
                context.getSystemService(Context.CONNECTIVITY_SERVICE);
        NetworkInfo activeNetwork = cm.getActiveNetworkInfo();

        if (activeNetwork != null && activeNetwork.isConnected()) {
            SingleTask.isOnline = true;
            Toast.makeText(context, "Online — syncing data", Toast.LENGTH_SHORT).show();
            syncPendingRecords(context);
        } else {
            SingleTask.isOnline = false;
            Toast.makeText(context, "Offline — data saved locally", Toast.LENGTH_LONG).show();
        }
    }

    private void syncPendingRecords(Context context) {
        List<Student> pending = db.getPendingStudents();
        if (pending.isEmpty()) return;

        Log.d("Sync", "Syncing " + pending.size() + " record(s) to Firebase");
        for (Student student : pending) {
            sendToServer(student);
        }
    }

    private void sendToServer(final Student student) {
        student.setStatus(1);
        databaseReference.push().setValue(student)
                .addOnCompleteListener(task -> {
                    if (task.isSuccessful()) {
                        db.markAsSynced(student);
                        db.deleteSyncedRecords();
                        Log.d("Sync", "Synced: " + student.getName());
                    } else {
                        Log.e("Sync", "Failed to sync: " + student.getName(),
                                task.getException());
                    }
                });
    }
}

What's fixed: The original pushed the same record to Firebase and then also tried to update the local row's status — but it never actually deleted the synced row, so records accumulated in the local database forever. Now the receiver deletes synced rows after a successful push. Also replaced Log.e calls for informational messages with Log.d.

MainActivity.java — The UI

package com.example.internetchecking;

import androidx.annotation.NonNull;
import androidx.appcompat.app.AppCompatActivity;

import android.content.IntentFilter;
import android.net.ConnectivityManager;
import android.os.Build;
import android.os.Bundle;
import android.util.Log;
import android.view.View;
import android.widget.ArrayAdapter;
import android.widget.EditText;
import android.widget.ListView;
import android.widget.Toast;

import com.google.firebase.database.DataSnapshot;
import com.google.firebase.database.DatabaseError;
import com.google.firebase.database.DatabaseReference;
import com.google.firebase.database.FirebaseDatabase;
import com.google.firebase.database.ValueEventListener;

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

public class MainActivity extends AppCompatActivity {

    private InternetCheck internetCheck;
    private Database db;
    private EditText tname;
    private DatabaseReference databaseReference;
    private ListView listView;
    private boolean receiverRegistered = false;

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

        FirebaseDatabase database = FirebaseDatabase.getInstance();
        databaseReference = database.getReference("student");

        db = new Database(this);
        internetCheck = new InternetCheck();

        // Register the receiver with the correct API for the device's Android version
        IntentFilter filter = new IntentFilter(ConnectivityManager.CONNECTIVITY_ACTION);
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
            registerReceiver(internetCheck, filter, Context.RECEIVER_NOT_EXPORTED);
        } else {
            registerReceiver(internetCheck, filter);
        }
        receiverRegistered = true;

        tname = findViewById(R.id.tname);
        listView = findViewById(R.id.mylist);

        loadDataFromFirebase();
    }

    /** Triggered by the "Store Data" button. */
    public void checkInternet(View view) {
        String name = tname.getText().toString().trim();
        if (name.isEmpty()) {
            Toast.makeText(this, "Enter a name first", Toast.LENGTH_SHORT).show();
            return;
        }

        Student student = new Student();
        student.setName(name);

        if (SingleTask.isOnline) {
            student.setStatus(1);
            Toast.makeText(this, "Online — saving to Firebase", Toast.LENGTH_SHORT).show();
            sendToServer(student);
        } else {
            student.setStatus(0);
            Toast.makeText(this, "Offline — saving locally", Toast.LENGTH_SHORT).show();
            sendToLocalDatabase(student);
        }

        tname.setText("");
    }

    private void sendToServer(Student student) {
        Log.d("Upload", "Sending to Firebase: " + student.getName());
        databaseReference.push().setValue(student)
                .addOnCompleteListener(task -> {
                    if (task.isSuccessful()) {
                        Toast.makeText(MainActivity.this,
                                "Saved to Firebase", Toast.LENGTH_SHORT).show();
                    } else {
                        Toast.makeText(MainActivity.this,
                                "Failed: " + task.getException(),
                                Toast.LENGTH_SHORT).show();
                    }
                });
    }

    private void sendToLocalDatabase(Student student) {
        Log.d("Upload", "Saving locally: " + student.getName());
        long id = db.insert(student);
        Toast.makeText(this,
                id > 0 ? "Saved locally" : "Save failed",
                Toast.LENGTH_SHORT).show();
    }

    private void loadDataFromFirebase() {
        databaseReference.addValueEventListener(new ValueEventListener() {
            @Override
            public void onDataChange(@NonNull DataSnapshot snapshot) {
                List<Student> students = new ArrayList<>();
                for (DataSnapshot child : snapshot.getChildren()) {
                    Student s = child.getValue(Student.class);
                    if (s != null) students.add(s);
                }
                listView.setAdapter(new ArrayAdapter<>(
                        MainActivity.this,
                        android.R.layout.simple_list_item_1,
                        students));
            }

            @Override
            public void onCancelled(@NonNull DatabaseError error) {
                Log.e("Firebase", "Read failed", error.toException());
            }
        });
    }

    @Override
    protected void onDestroy() {
        super.onDestroy();
        if (receiverRegistered) {
            unregisterReceiver(internetCheck);
        }
    }
}

What's fixed: The original registration code checked SDK_INT >= N and SDK_INT >= M, but both branches did the same thing — a bug. Also, the original never unregistered the receiver, which leaks memory and causes crashes when the activity is destroyed. This version handles the modern RECEIVER_NOT_EXPORTED flag required on Android 13+, tracks whether the receiver is registered, and cleans up in onDestroy. The input field is also cleared after each submit.

How the Sync Flow Works

Here's the sequence of events from the user's perspective:

1. Online Case

The user types a name and taps "Store Data". SingleTask.isOnline is true (set by the broadcast receiver earlier), so the record goes straight to Firebase via databaseReference.push().setValue(student). The ListView updates automatically through the value event listener.

2. Offline Case

If the device has no internet, the record is saved to SQLite with status = 0. The ListView still doesn't show it because it only reads from Firebase.

3. Coming Back Online

When the device regains connectivity, InternetCheck.onReceive() fires. It queries SQLite for all rows with status = 0 and pushes each one to Firebase. On a successful push, the row is marked status = 1 and then deleted from the local database.

Why this architecture matters

The user never sees a "network error." Data is always saved somewhere. When connectivity returns, sync happens in the background without user intervention. This is the same pattern used by Google Docs, Gmail, and Evernote — and it's what users expect from a modern app.

Common Pitfalls

  • Using the deprecated connectivity plugin. The original blog post used the old connectivity package. It's been replaced by connectivity_plus on the Flutter side, but for native Android, you use ConnectivityManager directly.
  • Forgetting to unregister the receiver. If you register in onCreate but don't unregister in onDestroy, the receiver leaks and your app may crash when a connectivity change fires after the activity is destroyed.
  • Using RECEIVER_NOT_EXPORTED incorrectly. Android 13+ requires you to pass an export flag when registering a receiver at runtime. For receivers registered programmatically, use RECEIVER_NOT_EXPORTED unless the receiver needs to be triggered by other apps.
  • Missing android:exported in the manifest. Since Android 12 (API 31), any component with an intent-filter must declare android:exported explicitly. Set it to false for the connectivity receiver.
  • Not clearing synced rows. If you mark rows as synced but never delete them, the local database grows forever. Delete synced rows after a successful push.
  • Assuming ConnectivityManager tells you the internet is reachable. It only tells you that a network is connected. A captive portal or a broken upstream will still report "connected". For true reachability, ping a known host.
  • Writing to SQLite on the main thread. Small writes are fine, but for bulk operations, use a background thread to avoid ANRs.
  • Firebase's own offline support. Realtime Database actually has built-in offline persistence. If you enable it with FirebaseDatabase.getInstance().setPersistenceEnabled(true), Firebase queues writes locally and syncs automatically — you may not need a separate SQLite layer at all for simple cases.

Wrapping Up

You now have a working offline-first data flow: save to SQLite when offline, push to Firebase when online, and sync pending records automatically via a broadcast receiver. This is a solid foundation for any app that needs to work reliably across flaky connections.

From here, you can extend the example with:

  • Firebase's built-in offline persistence. For simple use cases, setPersistenceEnabled(true) gives you offline support without any SQLite code at all.
  • WorkManager. Instead of relying on a broadcast receiver, use WorkManager to schedule a guaranteed sync job that survives app restarts and Doze mode.
  • Conflict resolution. If the same record is edited both offline and online, you'll need a strategy for merging changes — typically "last write wins" with a server timestamp.
  • Progress indicators. Show the user which records are pending sync so they know what's happening.
  • Room. For anything beyond a single table, use Room instead of raw SQLite. It's the modern, type-safe way to work with local databases in Android.

1 comment:

Suriyal said...

Thank you for this excellent code sir, very helpful while thinking of SQLite-Firebase sync