Search code examples
javaandroidapk-expansion-files

I'm lost with the Android Apk Exapnsion Procedure


My app recently reached the "maximum" size of 100 MBs so I had to learn about the apk expansion procedure. I read google's documentation but i found it kind of bad so i looked around and followed this guide: https://kitefaster.com/2017/02/15/expansion-apk-files-android-studio/ . Now, after adding the SampleDownloaderActivity,SampleDownloaderService and the SampleAlarmReceiver classes i got lost. I created a directory in my phone's internal storage in this location Android/obb/com.mypackage.example like the Google documentation mentions so i can put my expansion files there. But here are my questions:

1) I've added the required permissions in the app's manifest.xml file but do i need to ask for them first through code in order for the receiver,downloader and the downloaderActivity to work?

2) The files that i want to include in my main expansion file are some images from my xhdpi drawable folder but i am not sure on what i need to do in order to create my .obb file containing those images. Do i need to create a .zip file with them? Do i just drop them inside the directory i created that i mentioned above? What do i need to do?

3) Supposing the previous questions have been answered, if the files already exist in the directory or have been downloaded, i must get that dir through code and read the files in order to use them in my app, correct?

4) If the files are not there,how do i initiate the download from Google-Play? From what i understand by the doc, i need to make my app's main activity the "SampleDownloaderActivity" one, right?

5) After the files are done downloading, do i have to create an intent in the SampleDownloaderActivity's onCreate method in order for the app to go to my desired activity which uses those files?

Below, i'm posting the apk expansion related code files in which i've changed what i understood was needed. Do i need to change something else? Thank you in advance for your help!

ApkExpDownloaderService.java

public class ApkExpDownloaderService extends DownloaderService {
// stuff for LVL -- MODIFY FOR YOUR APPLICATION!
private static final String BASE64_PUBLIC_KEY = "MY_KEY";
// used by the preference obfuscater
private static final byte[] SALT = new byte[] {
        // my array of bytes
};

/**
 * This public key comes from your Android Market publisher account, and it
 * used by the LVL to validate responses from Market on your behalf.
 */
@Override
public String getPublicKey() {
    return BASE64_PUBLIC_KEY;
}

/**
 * This is used by the preference obfuscater to make sure that your
 * obfuscated preferences are different than the ones used by other
 * applications.
 */
@Override
public byte[] getSALT() {
    return SALT;
}

/**
 * Fill this in with the class name for your alarm receiver. We do this
 * because receivers must be unique across all of Android (it's a good idea
 * to make sure that your receiver is in your unique package)
 */
@Override
public String getAlarmReceiverClassName() {
    return ApkExpAlarmReceiver.class.getName();
}

}

ApkExpDownloaderActivity.java

public class ApkExpDownloaderActivity extends Activity implements IDownloaderClient {
private static final String LOG_TAG = "LVLDownloader";
private ProgressBar mPB;

private TextView mStatusText;
private TextView mProgressFraction;
private TextView mProgressPercent;
private TextView mAverageSpeed;
private TextView mTimeRemaining;

private View mDashboard;
private View mCellMessage;

private Button mPauseButton;
private Button mWiFiSettingsButton;

private boolean mStatePaused;
private int mState;

private IDownloaderService mRemoteService;

private IStub mDownloaderClientStub;

private void setState(int newState) {
    if (mState != newState) {
        mState = newState;
        mStatusText.setText(Helpers.getDownloaderStringResourceIDFromState(newState));
    }
}

private void setButtonPausedState(boolean paused) {
    mStatePaused = paused;
    int stringResourceID = paused ? R.string.text_button_resume :
            R.string.text_button_pause;
    mPauseButton.setText(stringResourceID);
}

/**
 * This is a little helper class that demonstrates simple testing of an
 * Expansion APK file delivered by Market. You may not wish to hard-code
 * things such as file lengths into your executable... and you may wish to
 * turn this code off during application development.
 */
private static class XAPKFile {
    public final boolean mIsMain;
    public final int mFileVersion;
    public final long mFileSize;

    XAPKFile(boolean isMain, int fileVersion, long fileSize) {
        mIsMain = isMain;
        mFileVersion = fileVersion;
        mFileSize = fileSize;
    }
}

/**
 * Here is where you place the data that the validator will use to determine
 * if the file was delivered correctly. This is encoded in the source code
 * so the application can easily determine whether the file has been
 * properly delivered without having to talk to the server. If the
 * application is using LVL for licensing, it may make sense to eliminate
 * these checks and to just rely on the server.
 */
private static final XAPKFile[] xAPKS = {
        new XAPKFile(
                true, // true signifies a main file
                21, // the version of the APK that the file was uploaded
                   // against
                687801613L // the length of the file in bytes
        )
};

/**
 * Go through each of the APK Expansion files defined in the structure above
 * and determine if the files are present and match the required size. Free
 * applications should definitely consider doing this, as this allows the
 * application to be launched for the first time without having a network
 * connection present. Paid applications that use LVL should probably do at
 * least one LVL check that requires the network to be present, so this is
 * not as necessary.
 * 
 * @return true if they are present.
 */
boolean expansionFilesDelivered() {
    for (XAPKFile xf : xAPKS) {
        String fileName = Helpers.getExpansionAPKFileName(this, xf.mIsMain, xf.mFileVersion);
        if (!Helpers.doesFileExist(this, fileName, xf.mFileSize, false))
            return false;
    }
    return true;
}

/**
 * Calculating a moving average for the validation speed so we don't get
 * jumpy calculations for time etc.
 */
static private final float SMOOTHING_FACTOR = 0.005f;

/**
 * Used by the async task
 */
private boolean mCancelValidation;

/**
 * Go through each of the Expansion APK files and open each as a zip file.
 * Calculate the CRC for each file and return false if any fail to match.
 * 
 * @return true if XAPKZipFile is successful
 */
void validateXAPKZipFiles() {
    AsyncTask<Object, DownloadProgressInfo, Boolean> validationTask = new AsyncTask<Object, DownloadProgressInfo, Boolean>() {

        @Override
        protected void onPreExecute() {
            mDashboard.setVisibility(View.VISIBLE);
            mCellMessage.setVisibility(View.GONE);
            mStatusText.setText(R.string.text_verifying_download);
            mPauseButton.setOnClickListener(new View.OnClickListener() {
                @Override
                public void onClick(View view) {
                    mCancelValidation = true;
                }
            });
            mPauseButton.setText(R.string.text_button_cancel_verify);
            super.onPreExecute();
        }

        @Override
        protected Boolean doInBackground(Object... params) {
            for (XAPKFile xf : xAPKS) {
                String fileName = Helpers.getExpansionAPKFileName(
                        ApkExpDownloaderActivity.this,
                        xf.mIsMain, xf.mFileVersion);
                if (!Helpers.doesFileExist(ApkExpDownloaderActivity.this, fileName,
                        xf.mFileSize, false))
                    return false;
                fileName = Helpers
                        .generateSaveFileName(ApkExpDownloaderActivity.this, fileName);
                ZipResourceFile zrf;
                byte[] buf = new byte[1024 * 256];
                try {
                    zrf = new ZipResourceFile(fileName);
                    ZipEntryRO[] entries = zrf.getAllEntries();
                    /**
                     * First calculate the total compressed length
                     */
                    long totalCompressedLength = 0;
                    for (ZipEntryRO entry : entries) {
                        totalCompressedLength += entry.mCompressedLength;
                    }
                    float averageVerifySpeed = 0;
                    long totalBytesRemaining = totalCompressedLength;
                    long timeRemaining;
                    /**
                     * Then calculate a CRC for every file in the Zip file,
                     * comparing it to what is stored in the Zip directory.
                     * Note that for compressed Zip files we must extract
                     * the contents to do this comparison.
                     */
                    for (ZipEntryRO entry : entries) {
                        if (-1 != entry.mCRC32) {
                            long length = entry.mUncompressedLength;
                            CRC32 crc = new CRC32();
                            DataInputStream dis = null;
                            try {
                                dis = new DataInputStream(
                                        zrf.getInputStream(entry.mFileName));

                                long startTime = SystemClock.uptimeMillis();
                                while (length > 0) {
                                    int seek = (int) (length > buf.length ? buf.length
                                            : length);
                                    dis.readFully(buf, 0, seek);
                                    crc.update(buf, 0, seek);
                                    length -= seek;
                                    long currentTime = SystemClock.uptimeMillis();
                                    long timePassed = currentTime - startTime;
                                    if (timePassed > 0) {
                                        float currentSpeedSample = (float) seek
                                                / (float) timePassed;
                                        if (0 != averageVerifySpeed) {
                                            averageVerifySpeed = SMOOTHING_FACTOR
                                                    * currentSpeedSample
                                                    + (1 - SMOOTHING_FACTOR)
                                                    * averageVerifySpeed;
                                        } else {
                                            averageVerifySpeed = currentSpeedSample;
                                        }
                                        totalBytesRemaining -= seek;
                                        timeRemaining = (long) (totalBytesRemaining / averageVerifySpeed);
                                        this.publishProgress(
                                                new DownloadProgressInfo(
                                                        totalCompressedLength,
                                                        totalCompressedLength
                                                                - totalBytesRemaining,
                                                        timeRemaining,
                                                        averageVerifySpeed)
                                                );
                                    }
                                    startTime = currentTime;
                                    if (mCancelValidation)
                                        return true;
                                }
                                if (crc.getValue() != entry.mCRC32) {
                                    Log.e(Constants.TAG,
                                            "CRC does not match for entry: "
                                                    + entry.mFileName);
                                    Log.e(Constants.TAG,
                                            "In file: " + entry.getZipFileName());
                                    return false;
                                }
                            } finally {
                                if (null != dis) {
                                    dis.close();
                                }
                            }
                        }
                    }
                } catch (IOException e) {
                    e.printStackTrace();
                    return false;
                }
            }
            return true;
        }

        @Override
        protected void onProgressUpdate(DownloadProgressInfo... values) {
            onDownloadProgress(values[0]);
            super.onProgressUpdate(values);
        }

        @Override
        protected void onPostExecute(Boolean result) {
            if (result) {
                mDashboard.setVisibility(View.VISIBLE);
                mCellMessage.setVisibility(View.GONE);
                mStatusText.setText(R.string.text_validation_complete);
                mPauseButton.setOnClickListener(new View.OnClickListener() {
                    @Override
                    public void onClick(View view) {
                        finish();
                    }
                });
                mPauseButton.setText(android.R.string.ok);
            } else {
                mDashboard.setVisibility(View.VISIBLE);
                mCellMessage.setVisibility(View.GONE);
                mStatusText.setText(R.string.text_validation_failed);
                mPauseButton.setOnClickListener(new View.OnClickListener() {
                    @Override
                    public void onClick(View view) {
                        finish();
                    }
                });
                mPauseButton.setText(android.R.string.cancel);
            }
            super.onPostExecute(result);
        }

    };
    validationTask.execute(new Object());
}

/**
 * If the download isn't present, we initialize the download UI. This ties
 * all of the controls into the remote service calls.
 */
private void initializeDownloadUI() {
    mDownloaderClientStub = DownloaderClientMarshaller.CreateStub
            (this, ApkExpDownloaderService.class);
    setContentView(R.layout.main);

    mPB = (ProgressBar) findViewById(R.id.progressBar);
    mStatusText = (TextView) findViewById(R.id.statusText);
    mProgressFraction = (TextView) findViewById(R.id.progressAsFraction);
    mProgressPercent = (TextView) findViewById(R.id.progressAsPercentage);
    mAverageSpeed = (TextView) findViewById(R.id.progressAverageSpeed);
    mTimeRemaining = (TextView) findViewById(R.id.progressTimeRemaining);
    mDashboard = findViewById(R.id.downloaderDashboard);
    mCellMessage = findViewById(R.id.approveCellular);
    mPauseButton = (Button) findViewById(R.id.pauseButton);
    mWiFiSettingsButton = (Button) findViewById(R.id.wifiSettingsButton);

    mPauseButton.setOnClickListener(new View.OnClickListener() {
        @Override
        public void onClick(View view) {
            if (mStatePaused) {
                mRemoteService.requestContinueDownload();
            } else {
                mRemoteService.requestPauseDownload();
            }
            setButtonPausedState(!mStatePaused);
        }
    });

    mWiFiSettingsButton.setOnClickListener(new View.OnClickListener() {

        @Override
        public void onClick(View v) {
            startActivity(new Intent(Settings.ACTION_WIFI_SETTINGS));
        }
    });

    Button resumeOnCell = (Button) findViewById(R.id.resumeOverCellular);
    resumeOnCell.setOnClickListener(new View.OnClickListener() {
        @Override
        public void onClick(View view) {
            mRemoteService.setDownloadFlags(IDownloaderService.FLAGS_DOWNLOAD_OVER_CELLULAR);
            mRemoteService.requestContinueDownload();
            mCellMessage.setVisibility(View.GONE);
        }
    });

}

/**
 * Called when the activity is first create; we wouldn't create a layout in
 * the case where we have the file and are moving to another activity
 * without downloading.
 */
@Override
public void onCreate(Bundle savedInstanceState) {
    super.onCreate(savedInstanceState);

    /**
     * Both downloading and validation make use of the "download" UI
     */
    initializeDownloadUI();

    /**
     * Before we do anything, are the files we expect already here and
     * delivered (presumably by Market) For free titles, this is probably
     * worth doing. (so no Market request is necessary)
     */
    if (!expansionFilesDelivered()) {

        try {
            Intent launchIntent = ApkExpDownloaderActivity.this
                    .getIntent();
            Intent intentToLaunchThisActivityFromNotification = new Intent(
                    ApkExpDownloaderActivity
                    .this, ApkExpDownloaderActivity.this.getClass());
            intentToLaunchThisActivityFromNotification.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK |
                    Intent.FLAG_ACTIVITY_CLEAR_TOP);
            intentToLaunchThisActivityFromNotification.setAction(launchIntent.getAction());

            if (launchIntent.getCategories() != null) {
                for (String category : launchIntent.getCategories()) {
                    intentToLaunchThisActivityFromNotification.addCategory(category);
                }
            }

            // Build PendingIntent used to open this activity from
            // Notification
            PendingIntent pendingIntent = PendingIntent.getActivity(
                    ApkExpDownloaderActivity.this,
                    0, intentToLaunchThisActivityFromNotification,
                    PendingIntent.FLAG_UPDATE_CURRENT);
            // Request to start the download
            int startResult = DownloaderClientMarshaller.startDownloadServiceIfRequired(this,
                    pendingIntent, ApkExpDownloaderService.class);

            if (startResult != DownloaderClientMarshaller.NO_DOWNLOAD_REQUIRED) {
                // The DownloaderService has started downloading the files,
                // show progress
                initializeDownloadUI();
                return;
            } // otherwise, download not needed so we fall through to
              // starting the movie
        } catch (NameNotFoundException e) {
            Log.e(LOG_TAG, "Cannot find own package! MAYDAY!");
            e.printStackTrace();
        }

    } else {
        validateXAPKZipFiles();
    }

}

/**
 * Connect the stub to our service on start.
 */
@Override
protected void onStart() {
    if (null != mDownloaderClientStub) {
        mDownloaderClientStub.connect(this);
    }
    super.onStart();
}

/**
 * Disconnect the stub from our service on stop
 */
@Override
protected void onStop() {
    if (null != mDownloaderClientStub) {
        mDownloaderClientStub.disconnect(this);
    }
    super.onStop();
}

//TODO:sp need more info on this
/**
 * Critical implementation detail. In onServiceConnected we create the
 * remote service and marshaler. This is how we pass the client information
 * back to the service so the client can be properly notified of changes. We
 * must do this every time we reconnect to the service.
 */
@Override
public void onServiceConnected(Messenger m) {
    mRemoteService = DownloaderServiceMarshaller.CreateProxy(m);
    mRemoteService.onClientUpdated(mDownloaderClientStub.getMessenger());
}

/**
 * The download state should trigger changes in the UI --- it may be useful
 * to show the state as being indeterminate at times. This sample can be
 * considered a guideline.
 */
@Override
public void onDownloadStateChanged(int newState) {
    setState(newState);
    boolean showDashboard = true;
    boolean showCellMessage = false;
    boolean paused;
    boolean indeterminate;
    switch (newState) {
        case IDownloaderClient.STATE_IDLE:
            // STATE_IDLE means the service is listening, so it's
            // safe to start making calls via mRemoteService.
            paused = false;
            indeterminate = true;
            break;
        case IDownloaderClient.STATE_CONNECTING:
        case IDownloaderClient.STATE_FETCHING_URL:
            showDashboard = true;
            paused = false;
            indeterminate = true;
            break;
        case IDownloaderClient.STATE_DOWNLOADING:
            paused = false;
            showDashboard = true;
            indeterminate = false;
            break;

        case IDownloaderClient.STATE_FAILED_CANCELED:
        case IDownloaderClient.STATE_FAILED:
        case IDownloaderClient.STATE_FAILED_FETCHING_URL:
        case IDownloaderClient.STATE_FAILED_UNLICENSED:
            paused = true;
            showDashboard = false;
            indeterminate = false;
            break;
        case IDownloaderClient.STATE_PAUSED_NEED_CELLULAR_PERMISSION:
        case IDownloaderClient.STATE_PAUSED_WIFI_DISABLED_NEED_CELLULAR_PERMISSION:
            showDashboard = false;
            paused = true;
            indeterminate = false;
            showCellMessage = true;
            break;

        case IDownloaderClient.STATE_PAUSED_BY_REQUEST:
            paused = true;
            indeterminate = false;
            break;
        case IDownloaderClient.STATE_PAUSED_ROAMING:
        case IDownloaderClient.STATE_PAUSED_SDCARD_UNAVAILABLE:
            paused = true;
            indeterminate = false;
            break;
        case IDownloaderClient.STATE_COMPLETED:
            showDashboard = false;
            paused = false;
            indeterminate = false;
            validateXAPKZipFiles();
            return;
        default:
            paused = true;
            indeterminate = true;
            showDashboard = true;
    }
    int newDashboardVisibility = showDashboard ? View.VISIBLE : View.GONE;
    if (mDashboard.getVisibility() != newDashboardVisibility) {
        mDashboard.setVisibility(newDashboardVisibility);
    }
    int cellMessageVisibility = showCellMessage ? View.VISIBLE : View.GONE;
    if (mCellMessage.getVisibility() != cellMessageVisibility) {
        mCellMessage.setVisibility(cellMessageVisibility);
    }

    mPB.setIndeterminate(indeterminate);
    setButtonPausedState(paused);
}

/**
 * Sets the state of the various controls based on the progressinfo object
 * sent from the downloader service.
 */
@Override
public void onDownloadProgress(DownloadProgressInfo progress) {
    mAverageSpeed.setText(getString(R.string.kilobytes_per_second,
            Helpers.getSpeedString(progress.mCurrentSpeed)));
    mTimeRemaining.setText(getString(R.string.time_remaining,
            Helpers.getTimeRemaining(progress.mTimeRemaining)));

    progress.mOverallTotal = progress.mOverallTotal;
    mPB.setMax((int) (progress.mOverallTotal >> 8));
    mPB.setProgress((int) (progress.mOverallProgress >> 8));
    mProgressPercent.setText(Long.toString(progress.mOverallProgress
            * 100 /
            progress.mOverallTotal) + "%");
    mProgressFraction.setText(Helpers.getDownloadProgressString
            (progress.mOverallProgress,
                    progress.mOverallTotal));
}

@Override
protected void onDestroy() {
    this.mCancelValidation = true;
    super.onDestroy();
}

}

ApkExpReceiver.java

public class ApkExpAlarmReceiver extends BroadcastReceiver {

@Override
public void onReceive(Context context, Intent intent) {
    try {
        DownloaderClientMarshaller.startDownloadServiceIfRequired(context, intent, ApkExpDownloaderService.class);
    } catch (NameNotFoundException e) {
        e.printStackTrace();
    }       
}

}

Solution

    1. Permissions is a bigger topic and independent of OBB files. Rather than explain it here, I recommend the documentation. In short, for Android devices before 6.0 they only need to be in the manifest. For Android devices after 6.0 you should ask for them at an appropriate moment at runtime.
    2. OBB is as the name suggests an "Opaque" binary blob. You can choose whatever file format you want, google just treats it as a blob of bits. Lots of apps use zip files, but you don't have to, you can use whatever file format you like.
    3. Correct.
    4. The sample download activity is what the name suggests - a sample. You can use that activity, or you can re-use the parts of the code to do the downloading in your own app. Whatever you do, downloading may take a little while, needs permissions, and won't work while offline, so you need to display an appropriate user interface to the user for all these cases. What an appropriate UI is will vary for different apps.