Remote install for Android app using APK

Mohamad Ghaith Alzin
7 min readJun 3, 2022

--

Just a quick note before we get started! This has nothing to do with Google Play.

Overview

This is for developers who wish to install their Android apps remotely on a physical device.

Now, I am going to explain how it is possible easily for you to upgrade (re-install) your Android app that already exists (installed) on an Android device.

Well, to give you a real use case of this, it would be that you are developing an Android app for a robot that exists most of the time operating far away from the place where you do work on the development. So, if you do what I am telling you, you are about to stop wasting time and money going to the site where the robot exits and installing the app. Rather, you will be able to only generate the new APK file of your app and then upload and transfer it somehow to a local or online server. Then, an automatic check for a new APK would be possible by checking a JSON file data that has the new app version and if there is one, then download the new APK file and get it installed on your machine. Awesome, Right!

Implementation

Ok, let’s get our hands dirty with code:

As you can see from the graph above. Our system mainly consists of two parts:

a. Android App (JAVA) (minSdk 23) (targetSdk 32)

b. Local Server (NodeJS) (Express)

a. Android App Development:

First, Go ahead and start a new empty project.

Go to your AndroidManifest.xml file and add (WRITE_EXTERNAL_STORAGE) and (INTERNET) permissions above the application tag.

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

The internet permission is clear why; cause we need to download the APK file from the server and the other permission is to be able to save it inside our app folders (storage).

Since the WRITE_EXTERNAL_STORAGE is important for us and if it was not granted. A pop-up message will not pop automatically. So, we have to check if the permission is granted already or not!

To do so, write the following function inside the MainActivity of your app.

Define new variables: will be used inside verifyPermissions function.

private static final String TAG = "MainActivity";// integer value of the REQUEST_EXTERNAL_STORAGE is 1
private static final int REQUEST_EXTERNAL_STORAGE = 1;
// The string of the permission inside our Manifest file
private static String[] PERMISSIONS_STORAGE = {
Manifest.permission.WRITE_EXTERNAL_STORAGE
};
// progress dialog to be used when download in process
private static ProgressDialog progressDialog;
// Create a new UpdateApp instance
private UpdateApp update = new UpdateApp();

verifyPermissions function:

Check if the current app has permission granted and if not! request it.

public static Boolean verifyPermissions(Activity activity) {
int permission = ActivityCompat.checkSelfPermission(activity, Manifest.permission.WRITE_EXTERNAL_STORAGE);
if (permission != PackageManager.PERMISSION_GRANTED) {
ActivityCompat.requestPermissions(activity, PERMISSIONS_STORAGE, REQUEST_EXTERNAL_STORAGE);
return false;
}
return true;
}

Next, let’s call it inside our onCreate() so that we always check if we got permission or not at the beginning of the life cycle of our app.

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

boolean result = verifyPermissions(this);

update.setContext(getApplicationContext());
// inject update informer to MainActivity
update.informer = this;
if (result) {
runOnUiThread(() -> {
showProgressDialog();
checkForUpdates();
});
}
}

UpdateApp Class

Create a new class called (UpdateApp) or whatever makes sense to you.

I put some comments below with the code. However, I will explain the key point behind this class.

First, it extends the AsyncTask which is helpful to do work in the background. Downloading the APK file is huge work on our app so we don’t block other actions we need to do.

There is onProgressUpdate() function that could be invoked automatically whenever publishProgress() is found in the doInBackground(String… arg).

We can make use of it so that the Informer interface(find its definition after this class) could be able to send the percentage of the file that is being downloaded back to the MainActivity so that we will be able to update the app screen on the ProgressDialog.

After the successful download of the APK file, we can start installing it.

After we finish all the above, onPostExecute will be called automatically and it has the meaning that our intended task has already finished.

public class UpdateApp extends AsyncTask<String, Void, String> {
private static final String TAG = "UpdateApp";
private Context context;
public Informer informer = null;
private String percent = "";

public void setContext(Context context) {
this.context = context;
}

@Override
protected void onPreExecute() {
super.onPreExecute();
}


@Override
protected void onProgressUpdate(Void... values) {
super.onProgressUpdate(values);
informer.setPercent(percent);
}

@Override
protected String doInBackground(String... arg) {

try {

// Initialise a new GET connection with the provided URL
URL url = new URL(arg[0]);
HttpURLConnection connection = (HttpURLConnection) url.openConnection();
connection.setRequestMethod("GET");
connection.connect();

int fileLength = connection.getContentLength();

// path to app /download/
String PATH = Environment.getExternalStorageDirectory() + "/download/";
File file = new File(PATH);
file.mkdirs();

// create a new File that has name of app.apk
File outputFile = new File(file, "app.apk");

if (outputFile.exists()) {
outputFile.delete();
}

FileOutputStream fos = new FileOutputStream(outputFile);
InputStream is = connection.getInputStream();
byte[] buffer = new byte[1024];

int downloadPercentage = 0;
long total = 0;
int len = 0;
while ((len = is.read(buffer)) != -1) {

fos.write(buffer, 0, len);
total += len;

if (fileLength > 0) {

downloadPercentage = (int) (total * 100 / fileLength);
percent = downloadPercentage + "%";

// important to slow down the thread, so that we get stable Progress update suitable for eye
SystemClock.sleep((long) 0.01);

// this call onProgressUpdate function (look above)
publishProgress();
}
}// end of while loop

fos.close();
is.close();

// After successful download, install the new version of the app immediately
Intent intent = new Intent(Intent.ACTION_VIEW);
intent.setDataAndType(Uri.fromFile(new File(Environment.getExternalStorageDirectory() + "/download/" + "app.apk")), "application/vnd.android.package-archive");
intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
context.startActivity(intent);

} catch (Exception e) {
Log.e(TAG, "Update Error: " + e.getMessage());
}

return null;
}

@Override
protected void onPostExecute(String s) {
super.onPostExecute(s);
informer.finished(s);
}
}

Informer Interface

public interface Informer {
void finished(String output);
void setPercent(String percent);
}

Main Activity

Now, let’s get back to the MainActivity and define downloadData function that will be called to download the JSON data.

So basically, it takes a URL of a JSON file on the server and downloads its data and if there is no error, returns the data as a String.

public static String downloadData(String strUrl) throws IOException {
String data = "";
try {

URL url = new URL(strUrl);
HttpURLConnection connection = (HttpURLConnection) url.openConnection();
connection.setRequestMethod("GET");
connection.connect();

InputStream is = connection.getInputStream();
BufferedReader br = new BufferedReader(new InputStreamReader(is));
StringBuffer sb = new StringBuffer();

String line = "";
while ((line = br.readLine()) != null) {
sb.append(line);
}

data = sb.toString();

br.close();
is.close();

} catch (Exception e) {
progressDialog.dismiss();
Log.d(TAG, "downloadData: Error! ", e);
}

return data;
}

Then, we need to create the function that will request downloading the JSON file and check if the version in the JSON file is newer than the current version of the app

public void checkForUpdates() {

// Download JSON on a new thread
new Thread(new Runnable() {
@Override
public void run() {
try {

// this is the path for the JSON file with local IP on my local
// server http://172.20.10.3:8080/check
String data = downloadData("http://172.20.10.3:8080/check");
JSONObject jsonObject = new JSONObject(data);
JSONObject elements = jsonObject.getJSONArray("elements").getJSONObject(0);

int newVersionCode = Integer.parseInt(elements.getString("versionCode"));
PackageInfo info = getPackageManager().getPackageInfo(getPackageName(), 0);

// if the new version code is greater than the current one, then //start downloading process.
if (info.versionCode < newVersionCode) {
update.execute("http://172.20.10.3:8080/update");
} else {
progressDialog.dismiss();
}
} catch (IOException | JSONException | PackageManager.NameNotFoundException e) {
progressDialog.dismiss();
e.printStackTrace();
showNegativeMessage();
}
}
}).start();
}

Then, let’s write the function showProgressDialog() that shows and sets up the progress dialog.

private void showProgressDialog() {
progressDialog = new ProgressDialog(this);
progressDialog.setMessage("Downloading...");
progressDialog.setTitle("Connecting to Server");
progressDialog.setIndeterminate(false);
progressDialog.setCancelable(true);
progressDialog.show();
}

Then, if you would like to we can show a Toast if there was an error in downloading the JSON data.

private void showNegativeMessage() {
runOnUiThread(new Runnable() {
@Override
public void run() {
Toast.makeText(MainActivity.this, "Error in downloading JSON data", Toast.LENGTH_LONG).show();
}
});
}

Now, our MainActivity needs to implement the informer in order to be informed whenever the updating of the new version has finished and to keep track of the percentage of the download.

public class MainActivity extends AppCompatActivity implements Informer

Then, to finish our Android development write these:

@Override
public void finished(String output) {
progressDialog.dismiss();
}

@Override
public void setPercent(String percent) {
progressDialog.setMessage("Downloading " + percent);
}

b. Local Server (NodeJS) (Express) :

For this, you need nodeJS already installed on your server.

Then, create a new directory, and type

touch inedx.js
npm init // Keep pressing enter after this
npm i express

Now, in index.js write

const express = require('express');const app = express();const hostname = '172.20.10.3'; // HERE YOU HAVE TO CHECK YOUR //SERVER IP addressconst port = 8080;app.use(express.static(__dirname));app.get("/", (req, res) => {res.send("This is home page.");// For testing purposes});app.get("/update", (req, res) => {// provide apk file when access
res.redirect("/.apk_repo/app.apk");
});app.get("/check", (req, res) => {// provide json file when acces
res.redirect("/.apk_repo/app.json");
});app.listen(port, hostname, () => {console.log(`Server running at http://${hostname}:${port}/`);});

If you have noticed that we got .apk_repo folder and inside it. you have to place app.apk file of your Android app and the app.json file that is being generated each time when you generate the APK file using Android studio.

To generate the APK and its JSON data, go to the project of your app in Android Studio and navigate to Build / Build Apks. Press on that and wait until the build ends and now the generated files would be in a path like:

C:\Users\USER_NAME\AndroidStudioProjects\APP_NAME\app\build\outputs\apk\debug

You will see app-debug.apk and output-metadata and these both are being copied/pasted to the above folders in our server but you need to change their name to app.apk and app.json. Or keep them but you need then to change their names in the JS code above.

Now, it’s time to start running our local server and being able to receive requests from our app (client).

You can use:

node index.js

Or I strongly recommend you to install Nodemon and do it like this:

# Install nodemon globally on your machine 
npm install -g nodemon

if you use Nodemon, then you have to edit the package.json to

{"dependencies": {"express": "^4.18.1"},"scripts": {"start": "nodemon index.js"}}

Then,

npm start

--

--