Implementing Remote APK Download and Update Functionality in Android Applications
An application reqiures several Manifest permissions to enable downolading and installing APK files from a remote source.
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
<uses-permission android:name="android.permission.MOUNT_UNMOUNT_FILESYSTEMS" tools:ignore="ProtectedPermissions" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
Within the application tag, configure network security and a FileProvider for Nougat and above compatibility.
<application
android:networkSecurityConfig="@xml/network_config"
... >
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/provider_paths" />
</provider>
</application>
Define the provider paths in res/xml/provider_paths.xml.
<?xml version="1.0" encoding="utf-8"?>
<paths xmlns:android="http://schemas.android.com/apk/res/android">
<external-files-path
name="app_updates"
path="Downloads/" />
</paths>
Create res/xml/network_config.xml to allow cleartext traffic for HTTP downloads.
<?xml version="1.0" encoding="utf-8"?>
<network-security-config>
<base-config cleartextTrafficPermitted="true"/>
</network-security-config>
A custom PackageUpdater class handles the core logic: checking for updates, downloading, and installing.
import android.app.AlertDialog;
import android.content.Context;
import android.content.DialogInterface;
import android.content.Intent;
import android.net.Uri;
import android.os.Build;
import android.os.Environment;
import android.os.Handler;
import android.os.Looper;
import android.os.Message;
import android.widget.ProgressBar;
import android.widget.TextView;
import android.widget.Toast;
import androidx.core.content.FileProvider;
import java.io.*;
import java.net.HttpURLConnection;
import java.net.URL;
public class PackageUpdater {
private Context appContext;
private String remoteApkUrl;
private String versionCheckUrl;
private File localApkFile;
private ProgressBar downloadProgressBar;
private TextView statusTextView;
private boolean downloadCancelled = false;
private Handler uiHandler;
private static final int MSG_SHOW_UPDATE = 1;
private static final int MSG_UPDATE_PROGRESS = 2;
private static final int MSG_DOWNLOAD_COMPLETE = 3;
public PackageUpdater(Context context, String baseUrl) {
this.appContext = context;
this.remoteApkUrl = baseUrl + "/latest.apk";
this.versionCheckUrl = baseUrl + "/version-info";
File downloadsDir = context.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS);
this.localApkFile = new File(downloadsDir, "update_package.apk");
this.uiHandler = new Handler(Looper.getMainLooper()) {
@Override
public void handleMessage(Message msg) {
handleUiMessage(msg);
}
};
}
public void verifyVersion() {
new Thread(() -> {
try {
String serverVersion = fetchServerVersion();
String clientVersion = getAppVersionName();
if (isNewVersionAvailable(clientVersion, serverVersion)) {
uiHandler.sendEmptyMessage(MSG_SHOW_UPDATE);
}
} catch (Exception e) {
e.printStackTrace();
}
}).start();
}
private String fetchServerVersion() throws IOException {
HttpURLConnection conn = null;
BufferedReader reader = null;
try {
URL url = new URL(versionCheckUrl);
conn = (HttpURLConnection) url.openConnection();
conn.setRequestMethod("GET");
InputStream is = conn.getInputStream();
reader = new BufferedReader(new InputStreamReader(is));
StringBuilder builder = new StringBuilder();
String line;
while ((line = reader.readLine()) != null) {
builder.append(line);
}
return builder.toString();
} finally {
if (reader != null) reader.close();
if (conn != null) conn.disconnect();
}
}
private String getAppVersionName() {
try {
return appContext.getPackageManager()
.getPackageInfo(appContext.getPackageName(), 0).versionName;
} catch (Exception e) {
return "1.0";
}
}
private boolean isNewVersionAvailable(String local, String remote) {
return !local.equals(remote);
}
private void showUpdatePrompt() {
new AlertDialog.Builder(appContext)
.setTitle("Update Available")
.setMessage("A new version is ready to download.")
.setPositiveButton("Download", (dialog, which) -> startDownload())
.setCancelable(false)
.show();
}
private void startDownload() {
AlertDialog progressDialog = createProgressDialog();
progressDialog.show();
new Thread(() -> performDownload(progressDialog)).start();
}
private AlertDialog createProgressDialog() {
AlertDialog.Builder builder = new AlertDialog.Builder(appContext);
builder.setTitle("Downloading Update");
builder.setView(R.layout.dialog_download_progress);
builder.setNegativeButton("Cancel", (dialog, which) -> downloadCancelled = true);
AlertDialog dialog = builder.create();
dialog.setCanceledOnTouchOutside(false);
dialog.setOnShowListener(dialogInterface -> {
downloadProgressBar = dialog.findViewById(R.id.progress_bar);
statusTextView = dialog.findViewById(R.id.status_text);
});
return dialog;
}
private void performDownload(AlertDialog dialog) {
try {
URL downloadUrl = new URL(remoteApkUrl);
HttpURLConnection connection = (HttpURLConnection) downloadUrl.openConnection();
connection.connect();
int fileSize = connection.getContentLength();
InputStream input = connection.getInputStream();
FileOutputStream output = new FileOutputStream(localApkFile);
byte[] buffer = new byte[4096];
int totalBytesRead = 0;
int bytesRead;
while ((bytesRead = input.read(buffer)) != -1 && !downloadCancelled) {
output.write(buffer, 0, bytesRead);
totalBytesRead += bytesRead;
int percent = (int) ((totalBytesRead * 100.0f) / fileSize);
Message msg = uiHandler.obtainMessage(MSG_UPDATE_PROGRESS, percent, 0);
uiHandler.sendMessage(msg);
}
output.close();
input.close();
connection.disconnect();
if (!downloadCancelled) {
uiHandler.sendEmptyMessage(MSG_DOWNLOAD_COMPLETE);
dialog.dismiss();
}
} catch (IOException e) {
e.printStackTrace();
}
}
private void handleUiMessage(Message msg) {
switch (msg.what) {
case MSG_SHOW_UPDATE:
showUpdatePrompt();
break;
case MSG_UPDATE_PROGRESS:
int progress = msg.arg1;
if (downloadProgressBar != null) downloadProgressBar.setProgress(progress);
if (statusTextView != null) statusTextView.setText(progress + "%");
break;
case MSG_DOWNLOAD_COMPLETE:
executeInstallation();
break;
}
}
private void executeInstallation() {
Intent installIntent = new Intent(Intent.ACTION_VIEW);
installIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
installIntent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);
Uri apkUri;
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
String authority = appContext.getPackageName() + ".fileprovider";
apkUri = FileProvider.getUriForFile(appContext, authority, localApkFile);
} else {
apkUri = Uri.fromFile(localApkFile);
}
installIntent.setDataAndType(apkUri, "application/vnd.android.package-archive");
appContext.startActivity(installIntent);
}
}
Define the progress dialog layout in res/layout/dialog_download_progress.xml.
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="16dp">
<ProgressBar
android:id="@+id/progress_bar"
style="?android:attr/progressBarStyleHorizontal"
android:layout_width="match_parent"
android:layout_height="wrap_content" />
<TextView
android:id="@+id/status_text"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center_horizontal"
android:text="0%"
android:layout_marginTop="8dp" />
</LinearLayout>
In your main activity, request runtime permissions and initialize the updater.
import android.Manifest;
import android.content.pm.PackageManager;
import android.os.Bundle;
import android.os.Handler;
import androidx.appcompat.app.AppCompatActivity;
import androidx.core.app.ActivityCompat;
import androidx.core.content.ContextCompat;
import java.util.ArrayList;
import java.util.List;
public class MainActivity extends AppCompatActivity {
private PackageUpdater appUpdater;
private static final int PERMISSION_REQUEST_CODE = 200;
private Handler periodicCheckHandler = new Handler();
private static final long CHECK_INTERVAL = 3600000; // 1 hour
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
appUpdater = new PackageUpdater(this, "https://your-server.com/api");
verifyPermissionsAndCheckUpdate();
startPeriodicUpdateCheck();
}
private void verifyPermissionsAndCheckUpdate() {
String[] requiredPermissions = {
Manifest.permission.READ_EXTERNAL_STORAGE,
Manifest.permission.WRITE_EXTERNAL_STORAGE,
Manifest.permission.INTERNET
};
List<String> pendingPermissions = new ArrayList<>();
for (String perm : requiredPermissions) {
if (ContextCompat.checkSelfPermission(this, perm) != PackageManager.PERMISSION_GRANTED) {
pendingPermissions.add(perm);
}
}
if (pendingPermissions.isEmpty()) {
appUpdater.verifyVersion();
} else {
ActivityCompat.requestPermissions(this,
pendingPermissions.toArray(new String[0]),
PERMISSION_REQUEST_CODE);
}
}
@Override
public void onRequestPermissionsResult(int requestCode,
String[] permissions,
int[] grantResults) {
super.onRequestPermissionsResult(requestCode, permissions, grantResults);
if (requestCode == PERMISSION_REQUEST_CODE) {
boolean allGranted = true;
for (int result : grantResults) {
if (result != PackageManager.PERMISSION_GRANTED) {
allGranted = false;
break;
}
}
if (allGranted) {
appUpdater.verifyVersion();
}
}
}
private void startPeriodicUpdateCheck() {
periodicCheckHandler.postDelayed(new Runnable() {
@Override
public void run() {
verifyPermissionsAndCheckUpdate();
periodicCheckHandler.postDelayed(this, CHECK_INTERVAL);
}
}, CHECK_INTERVAL);
}
@Override
protected void onDestroy() {
super.onDestroy();
periodicCheckHandler.removeCallbacksAndMessages(null);
}
}
Configure the Gradle build file to generate unique version names and output filenames.
android {
compileSdkVersion 33
defaultConfig {
applicationId "com.example.app"
minSdkVersion 23
targetSdkVersion 33
versionCode 1
versionName generateVersionName()
}
buildTypes {
release {
minifyEnabled false
buildConfigField("String", "BASE_URL", '"https://prod-server.com/api"')
setProperty("archivesBaseName", "app_${generateVersionName()}")
}
debug {
buildConfigField("String", "BASE_URL", '"https://dev-server.com/api"')
applicationIdSuffix ".debug"
}
}
}
def generateVersionName() {
return new Date().format('yyyyMMdd.HHmm', TimeZone.getTimeZone('UTC'))
}