APK Expansion Files pour applications Android

Share Button

APK Expansion Files : à quoi ça sert ?

Si vous développez une application Android et que celle-ci dépasse les 50Mo, alors vous ne pourrez pas publier votre application sur Google Play à moins d’utiliser l’APK Expansion Files qui permettra de pallier ce problème. Les fichiers seront alors téléchargés en même temps que l’application et seront installés sur la carte SD. Sur quelques anciens téléphones, il faudra implémenter une portion de code pour télécharger ces ressources au lancement de l’application (nous en aurons également besoin lorsqu’une mise à jour des ressources sera faite).

Il est également possible de se passer de l’APK Expansion Files en déposant directement les ressources sur un serveur privé. Cette solution nous oblige à développer un module de téléchargement, d’avoir un serveur sécurisé sur lequel on déposera les ressources et développer un système pour gérer les mises à jour etc… bref un gros travail de développement.

Vous trouverez les explications officielles concernant l’utilisation de l’APK Expansion Files ici.

Présentation des fichiers APK Expansion Files

Lorsque vous téléchargez votre application sur Google Play, il vous sera proposé de lier 1 ou 2 fichiers d’extension à l’APK (). Chaque fichier peut contenir jusqu’à 2 GO de données et est censé avoir un rôle bien défini :

  • Le fichier d’extension principal est celui qui contient les ressources supplémentaires de l’application. Il ne doit être que rarement mis à jour voire jamais.
  • Le fichier d’extension de patch est optionnel et est destiné à faire des mises à jour du fichier principal et il est généralement plus petit que le fichier principal.

Format des fichiers

Le format des fichiers peut être de plusieurs types (ZIP, PDF, MP4, etc…). Vous pouvez également utiliser l’outil JOBB afin de crypter les fichiers de ressources. Dans tous les cas, Google Play considère tous les fichiers comme des fichiers « Opaque Blobs Binaires » (OBB) et les renomme en utilisant le schéma suivant :

[main|patch].<expansion-version>.<package-name>.obb

  • main / patch : indique si le fichier est un fichier d’extension principal ou un patch
  • <expansion-version> : correspond au code de version (android:versionCode) du premier APK auquel il a été lié. Il est précisé « le premier APK auquel il a été lié » car le fichier pourra être lié à d’autres versions de l’application mais sa version ne changera pas
  • <package-name> : le nom du package Java de l’application

Par exemple, notre application aura comme code version 1 donc le fichier d’extension principal sera nommé main.1.com.infinitestudio.apkef.obb

Chemin de stockage

Quand Google Play télécharge vos fichiers d’extension, il les enregistre dans l’emplacement de stockage partagé du système, c’est à dire dans <shared-storage>/Android/obb/<package-name>/.

  • <shared-storage> : chemin vers l’espace de stockage partagé, retourné par la méthode getExternalStorageDirectory().
  • <package-name> : le nom du package Java de l’application, retourné par la méthode getPackagename().

Exemple avec le fichier d’extension principal de notre application : sdcard/Android/obb/com.infinitestudio.apkef/main.1.com.infinitestudio.apkef.obb

Les versions précédentes des fichiers d’extension sont écrasées lorsque l’application est installée avec des nouveaux fichiers d’extension.

Préparation des projets sous Eclipse

Tout d’abord, vérifiez dans Android SDK Manager que vous avez installé les composants suivants dans la rubrique Extra :

  • Google Play APK Expansion Library
  • Google Play Licensing Library

Android SDK Manager

Nous allons maintenant créer 3 projets à partir de sources existantes :

  1. APK Expansion Zip Library
    • chemin vers les sources : ANDROID_SDK/extras/google/play_apk_expansion/zip_file
    • cette librairie va nous permettre de lire facilement les ressources de nos fichiers d’extension ZIP.
  2. Downloader Library
    • chemin vers les sources : ANDROID_SDK/extras/google/play_apk_expansion/downloader_library
    • cette librairie fournit un tas de fonctionnalités pour nous aider à télécharger nos ressources depuis les serveurs Google Play. Elle nous permet de télécharger les ressources dans un service en arrière plan, de mettre en pause et de reprendre le téléchargement, de notifier l’utilisateur de la fin du téléchargement etc…
  3. Application Licensing
    • chemin vers les sources : ANDROID_SDK/extras/google/play_licensing/library
    • cette librairie va nous permettre d’autoriser le téléchargement des ressources en utilisant le service Google Play Application Licensing et en lui fournissant la clé de licence de l’application.

Pour rappel, voici comment faire pour créer ces 3 projets sous Eclipse :

  1. Créez un nouveau projet Android en sélectionnant l’assistant Android Project from Existing Code.Android Project from Existing Code
  2. Ensuite importez le projet en spécifiant le chemin que je vous ai indiqué plus haut, ANDROID_SDK/extras/google/play_apk_expansion/zip_file pour le projet APK Expansion Zip Library par exemple.Import projectVous pouvez cocher la case Copy projects into workspace pour copier les sources du SDK dans votre workpsace.

Répétez l’opération pour les 2 autres projets et vous devriez avoir vos 3 projets importés dans Eclipse.

Vous devriez avoir des erreurs sur le projet Downloader Library car il a besoin de classes contenues dans le projet Application Licensing. Vous devez simplement lier le projet Application Licensing au projet Downloader Library en passant par les propriétés de celui-ci puis menu « Android » partie « Library ». Vous devriez obtenir ceci :

Liaison librairie Application Licencing

Il ne nous reste plus qu’à paramétrer notre projet pour lequel on veut implémenter l’APK Expansion Files. Il nous suffit simplement de lier les 3 projets, que nous venons de créer, à notre projet et vous devriez obtenir ceci :Liaison librairies à notre application

Veillez bien à ce que la case à cocher isLibrary soit décochée.

Vous trouverez les sources du projet que j’ai utilisé en téléchargement à la fin de cet article ou en téléchargement sur GitHub : https://github.com/Infinite-Studio/tuto-apk-expansion-files.

Création de l’application

Pour commencer, créez un nouveau projet Android et nommez le Tuto APK Expansion Files. Pour cette exemple, j’ai créé une simple activité qui va nous permettre de lire 2 vidéos que l’on pourra sélectionner dans le menu. Les vidéos seront placés dans le répertoire res/raw de mon projet. L’emplacement des vidéos est temporaire car elles seront plus tard chargées en tant que APK Expansion Files et ne seront pas intégrées dans l’APK.

Voici le code de l’activité :

TutoActivity.java

package com.infinitestudio.apkef.activity;

import android.app.Activity;
import android.net.Uri;
import android.os.Bundle;
import android.view.Menu;
import android.view.MenuInflater;
import android.view.MenuItem;
import android.widget.MediaController;
import android.widget.TextView;
import android.widget.VideoView;

import com.infinitestudio.apkef.R;

public class TutoActivity extends Activity {
	private static final String LOG_TAG = "TutoActivity";

	private static boolean isKill;

	private VideoView video;
	private TextView text;

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

		isKill = false;
	}

	@Override
	public boolean onCreateOptionsMenu(Menu menu) {
		MenuInflater inflater = getMenuInflater();
		inflater.inflate(R.menu.main_menu, menu);
		return true;
	}

	@Override
	public boolean onOptionsItemSelected(MenuItem item) {
		int videoId = 0;
		switch (item.getItemId()) {
			case R.id.menu_scrat_01 :
				videoId = R.raw.scrat_01;
				break;
			case R.id.menu_scrat_02 :
				videoId = R.raw.scrat_02;
				break;
		}

		video = (VideoView) findViewById(R.id.video_view);
		video.setVideoURI(Uri.parse("android.resource://" + getApplicationContext().getPackageName() + "/" + videoId));
        video.setMediaController(new MediaController(this));
        video.setVisibility(VideoView.VISIBLE);
        video.start();

        text = (TextView) findViewById(R.id.info_text);
        text.setVisibility(TextView.INVISIBLE);
		return super.onOptionsItemSelected(item);
	}

	@Override
	public void onBackPressed() {
		isKill = true;
		super.onBackPressed();
	}

	@Override
	protected void onResume() {
		if (isKill) {
			finish();
		}
		super.onResume();
	}
}

et le layout associé :

activity_main.xml

<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="fill_parent"
    android:layout_height="fill_parent"
    android:background="#000000" >

    <TextView
        android:id="@+id/info_text"
        android:textColor="#FFFFFF"
        android:textStyle="bold"
        android:gravity="center"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:layout_centerInParent="true"
        android:text="@string/info_text"/>

    <VideoView
        android:id="@+id/video_view"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:layout_centerInParent="true"
        android:fitsSystemWindows="true"
        android:visibility="invisible" />

</RelativeLayout>

Dans le fichier strings.xml, je déclare le nom de mon application et de mon activité ainsi que le nom des 2 vidéos que je vais charger :

strings.xml

<resources ...>
    <string name="app_name">Tuto APK Expansion Files</string>

    <string name="menu_scrat_01">Vidéo Scrat 01</string>
    <string name="menu_scrat_02">Vidéo Scrat 02</string>

    <string name="info_text">Appuyez sur le bouton \"Menu\" de votre téléphone pour choisir la vidéo à afficher.</string>
</resources>

Enfin je mets à jour mon manifest :

AndroidManifest.xml

<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    package="com.infinitestudio.apkef"
    android:versionCode="1"
    android:versionName="1.0" >

    <uses-sdk
        android:minSdkVersion="8"
        android:targetSdkVersion="18" />

    <application
        android:allowBackup="true"
        android:icon="@drawable/ic_launcher"
        android:label="@string/app_name"
        android:theme="@style/AppTheme" >
        <activity
            android:name=".activity.TutoActivity"
            android:label="@string/app_name">
             <intent-filter>
                <action android:name="android.intent.action.MAIN" />
                <category android:name="android.intent.category.LAUNCHER" />
            </intent-filter>
        </activity>
    </application>

</manifest>

Exécutez l’application et vous devriez obtenir ça :

Application accueil  Application sélection vidéo  Application lecture vidéo

En l’état les vidéos sont intégrées à l’application, nous allons donc voir maintenant comment la configurer et quels changements y apporter pour utiliser l’APK Expansion Files et ainsi pouvoir publier notre application sur Google Play.

Configuration de l’application

Nous allons maintenant configurer notre application pour qu’elle puisse utiliser le système APK Expansion Files.

Tout ce qui suit est expliqué par Google ici au chapitre « Preparing to use the Downloader Library ».

Déclaration des permissions utilisateur

Pour commencer, on va ajouter les permissions utilisateur dont nous avons besoin :

AndroidManifest.xml

<manifest ...>
	<!-- Nécessaire pour accéder à Google Play Licensing -->
	<uses-permission android:name="com.android.vending.CHECK_LICENSE" />

	<!-- Nécessaire pour télécharger les fichiers depuis Google Play -->
	<uses-permission android:name="android.permission.INTERNET" />

	<!-- Nécessaire pour garder le CPU actif pendant le téléchargement des fichiers (Ne garde pas l'écran allumé) -->
	<uses-permission android:name="android.permission.WAKE_LOCK" />

	<!-- Nécessaire pour connaître l'état de la connexion réseau et répondre aux changements -->
	<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />

	<!-- Nécessaire pour vérifier si la connexion WI-FI est activée -->
	<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />

	<!-- Nécessaire pour lire et écrire les fichiers d'extension sur un stockage partagé -->
	<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
	<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
	<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
</manifest>

Implémentation du service « DownloaderService »

Cette classe va nous permettre de gérer le téléchargement des fichiers depuis la plateforme Google Play, mais également plein d’autres choses :

  • mettre en pause le téléchargement s’il n’y a plus de connexion internet par exemple, et de le reprendre lorsque la connexion a été rétablie
  • planifier une alarme « RTC_WAKEUP » pour relancer le téléchargement au cas où le service a été tué
  • mettre en place des notifications personnalisées pour afficher l’état du téléchargement, les erreurs et les changements d’état
  • permettre à l’utilisateur de mettre en pause et de reprendre le téléchargement manuellement

APKEFTutoDownloaderService.java

package com.infinitestudio.apkef.service;

import com.google.android.vending.expansion.downloader.impl.DownloaderService;
import com.infinitestudio.apkef.receiver.APKEFTutoAlarmReceiver;

public class APKEFTutoDownloaderService extends DownloaderService {
	// Votre clé publique fournie par Google Play
    public static final String BASE64_PUBLIC_KEY = "votre_cle_publique";

    // Vous devez également modifier ces chiffres, ils doivent être compris entre -128 et +127
    public static final byte[] SALT = new byte[] { 1, 42, -12, -1, 54, 98, -100, -12, 43, 2, -8, -4, 9, 5, -106, -107, -33, 45, -1, 84
    };

    @Override
    public String getPublicKey() {
        return BASE64_PUBLIC_KEY;
    }

    @Override
    public byte[] getSALT() {
        return SALT;
    }

    @Override
    public String getAlarmReceiverClassName() {
        return TutoAPKEFAlarmReceiver.class.getName();
    }
}

Nous devons surcharger 3 méthodes de la classe DownloaderService afin de fournir des détails spécifiques de l’application :

  1. getPublicKey() : retourne la chaîne correspondant à votre clé publique disponible sur le compte « Google Developer » de votre application dans le menu « Services et API ». Clé de licence
  2. getSALT() : retourne un tableau d’octets aléatoires, utilisé pour créer un Obfuscator. Cela nous assure que le fichier « SharedPreferences » occulté dans lequel nos données de licence sont enregistrées sera unique et non détectable.
  3. getAlarmReceiverClassName() : retourne le nom de la classe du BroadcastReceiver de notre application qui doit recevoir l’alarme, indiquant que le téléchargement doit être redémarré.

Ne pas oublier de déclarer le service dans le manifest :

AndroidManifest.xml

<application ...>
	<service android:name=".service.APKEFTutoDownloaderService" />
</application>

Implémentation du receiver « BroadcastReceiver »

Cette classe va nous permettre de suivre l’avancement du téléchargement des fichiers ou le redémarrer si nécessaire.

Il nous suffit de surcharger la méthode onReceive() et d’appeler DownloaderClientMarshaller.startDownloadServiceIfRequired().

APKEFTutoAlarmReceiver.java

package com.infinitestudio.apkef.receiver;

import android.content.BroadcastReceiver;
import android.content.Context;
import android.content.Intent;
import android.content.pm.PackageManager.NameNotFoundException;

import com.google.android.vending.expansion.downloader.DownloaderClientMarshaller;
import com.infinitestudio.apkef.service.APKEFTutoDownloaderService;

public class APKEFTutoAlarmReceiver extends BroadcastReceiver {

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

Ne pas oublier de déclarer le receiver dans le manifest :

AndroidManifest.xml

<application ...>
	<receiver android:name=".receiver.APKEFTutoAlarmReceiver" />
</application>

Implémentation du ContentProvider « APEZProvider »

Dans notre exemple nous voulons lire 2 vidéos présentes dans nos fichiers d’extension, en passant leur URI à la fonction VideoView.setVideoURI(). Pour pouvoir faire ça, il nous faut implémenter un ContentProvider qui nous fournira un accès aux fichiers via leur URI.

package com.infinitestudio.apkef.provider;

import com.android.vending.expansion.zipfile.APEZProvider;

public class ZipFileContentProvider extends APEZProvider {

    @Override
    public String getAuthority() {
        return "com.infinitestudio.apkef.provider.ZipFileContentProvider";
    }
}

Cette classe n’est à implémenter que si vous devez utiliser une URI pour accéder aux ressources présentes dans les fichiers d’extension.

Il faut également déclarer le provider dans le manifest :

AndroidManifest.xml

<application ...>
	<provider
		android:name=".provider.ZipFileContentProvider"
		android:authorities="com.infinitestudio.apkef.provider.ZipFileContentProvider"
		android:exported="false" >
	</provider>
</application>

Vous remarquerez que j’ai utilisé ici la propriété android:exported=false qui empêche les autres applications d’avoir accès au provider. Seules les applications ayant le même UID que le provider y auront accès. Voir la doc de l’élément <provider>.

Création de l’activité « DownloaderActivity »

Cette activité sera la page qui gèrera le téléchargement de l’APK Expansion Files. Cette page sera visible par l’utilisateur seulement si le téléchargement des fichiers d’extension a échoué, lorsque l’application a été téléchargée ou si une mise à jour de l’application implique également la mise à jour des fichiers d’extension.

Un exemple de page de téléchargement est mis à disposition dans les exemples de l’APK Expansion Files. Il vous faut récupérer ces 2 fichiers dans le SDK Android :

  • SampleDownloaderActivity.java que vous trouverez à cet endroit : ANDROID_SDK/extras/google/play_apk_expansion/downloader_sample/src/com/example/expansion/downloader
  • main.xml que vous trouverez à cet endroit : ANDROID_SDK/extras/google/play_apk_expansion/downloader_sample/res/layout

Renommez les DownloaderActivity.java et downloader_activity.xml.

Des erreurs vont être détectées par Eclipse. Pour les corriger, il vous faut rajouter ces propriétés dans votre fichier strings.xml :

strings.xml

<resources ...>
    <string name="notification_download_complete">Téléchargement terminé</string>
    <string name="notification_download_failed">Echec du téléchargement</string>

    <string name="state_unknown">Erreur inconnue</string>
    <string name="state_idle">En attente de téléchargement</string>
    <string name="state_fetching_url">Recherche de téléchargement en cours</string>
    <string name="state_connecting">Connexion au serveur</string>
    <string name="state_downloading">Téléchargement des ressources</string>
    <string name="state_completed">Téléchargement terminé</string>
    <string name="state_paused_network_unavailable">Le réseau n\'est pas disponible</string>
    <string name="state_paused_network_setup_failure">Téléchargement en pause, tester le site internet</string>
    <string name="state_paused_by_request">Téléchargement en pause</string>
    <string name="state_paused_wifi_unavailable">Le WIFI n\'est pas disponible</string>
    <string name="state_paused_wifi_disabled">Le WIFI est désactivé</string>
    <string name="state_paused_roaming">Téléchargement en pause, itinérance</string>
    <string name="state_paused_sdcard_unavailable">Téléchargement en pause car la carte SD n\'est pas disponible</string>
    <string name="state_failed_unlicensed">Echec du téléchargement, veuillez vous rendre sur google play</string>
    <string name="state_failed_fetching_url">Echec du téléchargement, ressources non-disponibles</string>
    <string name="state_failed_sdcard_full">Echec du téléchargement, la carte SD est pleine</string>
    <string name="state_failed_cancelled">Téléchargement annulé</string>
    <string name="state_failed">Echec du téléchargement</string>

    <string name="kilobytes_per_second">%1$s KB/s</string>
    <string name="time_remaining">Temps restant: %1$s</string>
    <string name="time_remaining_notification">reste %1$s </string>

    <string name="text_paused_cellular">Voulez-vous télécharger le contenu depuis votre réseau mobile ? Ceci peut engendrer un surcoût selon votre opérateur</string>
    <string name="text_paused_cellular_2">Le téléchargement reprendra lorsque le réseau WIFI sera de nouveau opérationnel</string>
    <string name="text_button_resume_cellular">Continuer le téléchargement</string>
    <string name="text_button_wifi_settings">Paramètres Wi-Fi</string>
    <string name="text_verifying_download">Vérification du téléchargement</string>
    <string name="text_validation_complete">Téléchargement terminé. OK pour sortir.</string>
    <string name="text_validation_failed">Echec de validation du téléchargement.</string>
    <string name="text_button_pause">Mettre en pause</string>
    <string name="text_button_resume">Relancer le téléchargement</string>
    <string name="text_button_cancel">Annuler</string>
    <string name="text_button_cancel_verify">Annuler la vérification</string>
</resources>

Certaines propriétés sont juste des propriétés « surchargées » pour lesquelles vous pouvez définir un message différent de celui d’origine. Les valeurs « surchargées » sont celles définies dans le fichier strings.xml du projet Downloader Library.

Ensuite, dans le fichier DownloaderActivity.java, il vous suffit de remplacer les occurences de « SampleDownloaderService » par « APKEFTutoDownloaderService » et de remplacer « setContentView(R.layout.main) » par « setContentView(R.layout.downloader_activity) ».

Vous devriez obtenir les fichiers suivants :

DownloaderActivity.java

package com.infinitestudio.apkef.activity;

import java.io.DataInputStream;
import java.io.IOException;
import java.util.zip.CRC32;

import android.app.Activity;
import android.app.PendingIntent;
import android.content.Intent;
import android.content.pm.PackageManager.NameNotFoundException;
import android.os.AsyncTask;
import android.os.Bundle;
import android.os.Messenger;
import android.os.SystemClock;
import android.provider.Settings;
import android.util.Log;
import android.view.View;
import android.widget.Button;
import android.widget.ProgressBar;
import android.widget.TextView;

import com.android.vending.expansion.zipfile.ZipResourceFile;
import com.android.vending.expansion.zipfile.ZipResourceFile.ZipEntryRO;
import com.google.android.vending.expansion.downloader.Constants;
import com.google.android.vending.expansion.downloader.DownloadProgressInfo;
import com.google.android.vending.expansion.downloader.DownloaderClientMarshaller;
import com.google.android.vending.expansion.downloader.DownloaderServiceMarshaller;
import com.google.android.vending.expansion.downloader.Helpers;
import com.google.android.vending.expansion.downloader.IDownloaderClient;
import com.google.android.vending.expansion.downloader.IDownloaderService;
import com.google.android.vending.expansion.downloader.IStub;
import com.infinitestudio.apkef.R;
import com.infinitestudio.apkef.service.APKEFTutoDownloaderService;

/**
 * This is sample code for a project built against the downloader library. It
 * implements the IDownloaderClient that the client marshaler will talk to as
 * messages are delivered from the DownloaderService.
 */
public class DownloaderActivity 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
                    3, // the version of the APK that the file was uploaded
                       // against
                    687801613L // the length of the file in bytes
            ),
            new XAPKFile(
                    false, // false signifies a patch file
                    4, // the version of the APK that the patch file was uploaded
                       // against
                    512860L // the length of the patch 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(
                            DownloaderActivity.this,
                            xf.mIsMain, xf.mFileVersion);
                    if (!Helpers.doesFileExist(DownloaderActivity.this, fileName,
                            xf.mFileSize, false))
                        return false;
                    fileName = Helpers
                            .generateSaveFileName(DownloaderActivity.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, APKEFTutoDownloaderService.class);
        setContentView(R.layout.downloader_activity);

        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 = DownloaderActivity.this
                        .getIntent();
                Intent intentToLaunchThisActivityFromNotification = new Intent(
                        DownloaderActivity
                        .this, DownloaderActivity.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(
                        DownloaderActivity.this,
                        0, intentToLaunchThisActivityFromNotification,
                        PendingIntent.FLAG_UPDATE_CURRENT);
                // Request to start the download
                int startResult = DownloaderClientMarshaller.startDownloadServiceIfRequired(this,
                        pendingIntent, APKEFTutoDownloaderService.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();
    }

    /**
     * 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();
    }

}

downloader_activity.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="match_parent"
    android:orientation="vertical" >

    <LinearLayout
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:layout_weight="0"
        android:orientation="vertical" >

        <TextView
            android:id="@+id/statusText"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:layout_marginBottom="10dp"
            android:layout_marginLeft="5dp"
            android:layout_marginTop="10dp"
            android:textStyle="bold" />

        <LinearLayout
            android:id="@+id/downloaderDashboard"
            android:layout_width="fill_parent"
            android:layout_height="wrap_content"
            android:layout_below="@id/statusText"
            android:orientation="vertical" >

            <RelativeLayout
                android:layout_width="match_parent"
                android:layout_height="wrap_content"
                android:layout_weight="1" >

                <TextView
                    android:id="@+id/progressAsFraction"
                    style="@android:style/TextAppearance.Small"
                    android:layout_width="wrap_content"
                    android:layout_height="wrap_content"
                    android:layout_alignParentLeft="true"
                    android:layout_marginLeft="5dp"
                    android:text="0MB / 0MB" >
                </TextView>

                <TextView
                    android:id="@+id/progressAsPercentage"
                    style="@android:style/TextAppearance.Small"
                    android:layout_width="wrap_content"
                    android:layout_height="wrap_content"
                    android:layout_alignRight="@+id/progressBar"
                    android:text="0%" />

                <ProgressBar
                    android:id="@+id/progressBar"
                    style="?android:attr/progressBarStyleHorizontal"
                    android:layout_width="match_parent"
                    android:layout_height="wrap_content"
                    android:layout_below="@+id/progressAsFraction"
                    android:layout_marginBottom="10dp"
                    android:layout_marginLeft="10dp"
                    android:layout_marginRight="10dp"
                    android:layout_marginTop="10dp"
                    android:layout_weight="1" />

                <TextView
                    android:id="@+id/progressAverageSpeed"
                    style="@android:style/TextAppearance.Small"
                    android:layout_width="wrap_content"
                    android:layout_height="wrap_content"
                    android:layout_alignParentLeft="true"
                    android:layout_below="@+id/progressBar"
                    android:layout_marginLeft="5dp" />

                <TextView
                    android:id="@+id/progressTimeRemaining"
                    style="@android:style/TextAppearance.Small"
                    android:layout_width="wrap_content"
                    android:layout_height="wrap_content"
                    android:layout_alignRight="@+id/progressBar"
                    android:layout_below="@+id/progressBar" />
            </RelativeLayout>

            <LinearLayout
                android:id="@+id/downloaderDashboard"
                android:layout_width="fill_parent"
                android:layout_height="wrap_content"
                android:orientation="horizontal" >

                <Button
                    android:id="@+id/pauseButton"
                    android:layout_width="wrap_content"
                    android:layout_height="wrap_content"
                    android:layout_gravity="center_vertical"
                    android:layout_marginBottom="10dp"
                    android:layout_marginLeft="10dp"
                    android:layout_marginRight="5dp"
                    android:layout_marginTop="10dp"
                    android:layout_weight="0"
                    android:minHeight="40dp"
                    android:minWidth="94dp"
                    android:text="@string/text_button_pause" />

                <Button
                    android:id="@+id/cancelButton"
                    android:layout_width="wrap_content"
                    android:layout_height="wrap_content"
                    android:layout_gravity="center_vertical"
                    android:layout_marginBottom="10dp"
                    android:layout_marginLeft="5dp"
                    android:layout_marginRight="5dp"
                    android:layout_marginTop="10dp"
                    android:layout_weight="0"
                    android:minHeight="40dp"
                    android:minWidth="94dp"
                    android:text="@string/text_button_cancel"
                    android:visibility="gone" />
            </LinearLayout>
        </LinearLayout>
    </LinearLayout>

    <LinearLayout
        android:id="@+id/approveCellular"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:layout_weight="1"
        android:orientation="vertical"
        android:visibility="gone" >

        <TextView
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:layout_margin="10dp"
            android:id="@+id/textPausedParagraph1"
            android:text="@string/text_paused_cellular" />

        <TextView
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:layout_margin="10dp"
            android:id="@+id/textPausedParagraph2"
            android:text="@string/text_paused_cellular_2" />

        <LinearLayout
            android:id="@+id/buttonRow"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:orientation="horizontal" >

            <Button
                android:id="@+id/resumeOverCellular"
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                android:layout_gravity="center"
                android:layout_margin="10dp"
                android:text="@string/text_button_resume_cellular" />

            <Button
                android:id="@+id/wifiSettingsButton"
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                android:layout_gravity="center"
                android:layout_margin="10dp"
                android:text="@string/text_button_wifi_settings" />
            </LinearLayout>
    </LinearLayout>

</LinearLayout>

On déclare cette activité dans le manifest :

AndroidManifest.xml

<application ...>
	<activity
		android:name=".activity.DownloaderActivity"
		android:label="@string/download_title"
		android:screenOrientation="portrait">
	</activity>
</application>

On rajoute le titre de l’activité dans le fichier strings.xml :

strings.xml

<resources ...>
    <string name="download_title">Téléchargement des fichiers d\'extension</string>
</resources>

Configuration du téléchargement

Pour l’instant, nous n’avons fait que récupérer l’exemple fournis par Google et l’avons ajouté dans notre projet. Nous allons maintenant créer notre fichier d’extension et configurer le téléchargement du fichier d’extension ainsi la lecture de nos vidéos.

Création du fichier d’extension

Pour commencer, nous allons créer notre fichier d’extension principal main.1.com.infinitestudio.apkef.obb.

Tout d’abord, nous allons créer un *.zip des 2 vidéos présentes dans notre projet. Lors de la création du *.zip, il ne faut pas compresser les fichiers. Si vous utilisez 7zip il vous faut choisir l’option « aucun niveau de compression » :Création OBB

Ensuite, renommez simplement le fichier *.zip en main.1.com.infinitestudio.apkef.obb.

Gestion du téléchargement dans l’activité principale

Nous allons faire quelques modifications dans l’activité DownloaderActivity.java.

Tout d’abord, on modifie la fonction expansionFilesDelivered() :

DownloaderActivity.java

public static boolean expansionFilesDelivered(Context context) {
	for (XAPKFile xf : xAPKS) {
		String fileName = Helpers.getExpansionAPKFileName(context, xf.mIsMain, xf.mFileVersion);
		if (!Helpers.doesFileExist(context, fileName, xf.mFileSize, false))
			return false;
	}
	return true;
}

et on modifie l’appel à cette fonction depuis la méthode onCreate() :

DownloaderActivity.java

public void onCreate(Bundle savedInstanceState) { ...
	if (!expansionFilesDelivered(this)) {
		...
	}
}

Cette modification va nous permettre d’utiliser cette méthode depuis notre activité principale.

Nous déclarons ensuite 2 variables publiques et statiques :

DownloaderActivity.java

public static int initialAPKVersionCode = 1;
public static Long mainFileSize = 15970598L;
  • initialAPKVersionCode : version de l’application avec laquelle le fichier d’extension principal a été lié sur Google Play. Dans notre cas, ce sera la première fois donc ce sera la version 1 définie dans le manifest par la propriété android:versionCode=1.
  • mainFileSize : taille en bytes du fichier d’extension principal. Veillez bien à renseigner la taille réelle du fichier et non pas sa taille sur le disque : Taille réelle du fichier d'extension Il est crucial que vous mettiez la bonne taille du fichier, sinon les ressources ne se téléchargeront pas.

On modifie l’initialisation du tableau XAPKFile[] en désactivant l’utilisation d’un fichier d’extension de type patch et en renseignant les valeurs de taille et de code de version pour le fichier d’extension principal :

DownloaderActivity.java

private static final XAPKFile[] xAPKS = {
		new XAPKFile(
				true, // true signifies a main file
				initialAPKVersionCode, // the version of the APK that the file was uploaded
				   // against
				mainFileSize // the length of the file in bytes
		)/*,
		new XAPKFile(
				false, // false signifies a patch file
				4, // the version of the APK that the patch file was uploaded
				   // against
				512860L // the length of the patch file in bytes
		) */           
};

On rajoute maintenant le code lié au téléchargement et à la lecture des vidéos dans l’activité principale. Voici la mise à jour de la méthode onCreate() :

TutoActivity.java

protected void onCreate(Bundle savedInstanceState) {
	super.onCreate(savedInstanceState);
	setContentView(R.layout.activity_main);
	if(!"mounted".equals(Environment.getExternalStorageState())){
		text = (TextView) findViewById(R.id.info_text);
		text.setText("Problème d'accès à l'espace de stockage externe : " + Environment.getExternalStorageDirectory());
	}
	if (!DownloaderActivity.expansionFilesDelivered(this)) {
		final Intent downloadResourceIntent = new Intent(
				TutoActivity.this,
				DownloaderActivity.class);
		startActivity(downloadResourceIntent);
		overridePendingTransition(android.R.anim.fade_in,
				android.R.anim.fade_out);
	}
}

Dans un premier temps, on vérifie que l’accès à l’espace de stockage sur lequel les fichiers d’extensions seront téléchargés est accessible. S’il l’est, alors on regarde si les fichiers d’extensions sont déjà présents ou non. Si c’est le cas, l’application se lance normalement sinon la page de téléchargement des ressources s’affiche.

On rajoute une méthode qui va nous permettre de récupérer une vidéo en fonction de son nom dans notre fichier d’extension :

TutoActivity.java

private String getUriForVideo(int nameVideo) {
	ZipFileContentProvider contentProvider = new ZipFileContentProvider();
	String authority = contentProvider.getAuthority();
	Uri contentUri = Uri.parse("content://" + authority);
	String videoName = null;

	try {
		int versionCode = DownloaderActivity.initialAPKVersionCode;
		ZipResourceFile expansionFile = APKExpansionSupport
				.getAPKExpansionZipFile(this, versionCode, versionCode);
		if (expansionFile != null) {
			String videoFileName = getResources().getString(nameVideo);
			ZipEntryRO[] ziro = expansionFile.getAllEntries();
			for (ZipEntryRO entry : ziro) {
				if (entry.mFileName.equals(videoFileName)) {
					videoName = entry.mFileName;
				}
			}
		}
	} catch (IOException e) {
		Log.e(LOG_TAG, "Fichier d'extension principal introuvable");
	}
	return contentUri + "/" + videoName;
}

puis on rajoute dans notre fichier strings.xml le nom des 2 vidéos :

strings.xml

<resources ...>
    <string name="video_scrat_01">scrat_01.avi</string>
    <string name="video_scrat_02">scrat_02.avi</string>
</resources>

enfin, on modifie la façon de charger les vidéos en utilisant notre fonction getUriForVideo() :

TutoActivity.java

public boolean onOptionsItemSelected(MenuItem item) {
	int videoName = 0;
	switch (item.getItemId()) {
		case R.id.menu_scrat_01 :
			videoName = R.string.video_scrat_01;
			break;
		case R.id.menu_scrat_02 :
			videoName = R.string.video_scrat_02;
			break;
	}

	video = (VideoView) findViewById(R.id.video_view);
	video.setVideoURI(Uri.parse(getUriForVideo(videoName)));
	video.setMediaController(new MediaController(this));
	video.setVisibility(VideoView.VISIBLE);
	video.start();

	text = (TextView) findViewById(R.id.info_text);
	text.setVisibility(TextView.INVISIBLE);
	return super.onOptionsItemSelected(item);
}

Et voilà, il ne nous reste plus qu’à tester tout ça.

Tester l’application et l’APK Expansion Files

Nous allons voir maintenant comment tester le bon fonctionnement de notre application après l’implémentation de l’APK Expansion Files.

Test de lecture des ressources

Pour commencer, nous allons seulement voir si nous arrivons à lire correctement nos vidéos depuis le fichier d’extension, pour cela nous allons le déposer manuellement sur notre téléphone.

Allez sur votre téléphone dans le dossier /storage/sdcard0/Android/obb. Si le dossier « obb » n’existe pas,créez le. Ensuite, créez un dossier com.infinitestudio.apkef dans lequel vous copierez le fichier *.obb que nous avons créé précédemment.

Lancez l’application. Si le fichier *.obb est au bon endroit, bien nommé et que la taille renseignée dans DownloaderActivity.java est correcte, alors vous devriez pouvoir lancer et lire les vidéos.

Test de téléchargement du fichier d’extension

Connectez-vous à votre compte « Google Developer » et sélectionnez votre application puis importez votre application :

Import de l'application Tuto APK Expansion Files

A la fin de l’importation de l’application, une fenêtre s’ouvrira pour vous proposer d’ajouter un fichier d’extension : Import du fichier d'extension

En raison d’une limitation de la console développeur, vous devrez importer 2 fois l’APK avant de pouvoir lier le fichier d’extension. Voir les explications de Google ici.

Pour que le fichier d’extension soit accessible, vous devez renseigner les onglets « Fiche Google Play Store » et « Tarifs et disponibilités ».

Avant de lancer l’application, vérifiez que le fichier d’extension n’est pas déjà présent sur votre téléphone. Si c’est le cas, supprimez le.

Avant de terminer il y a un dernier point que je n’ai pas réussi à faire fonctionner. Par exemple si je passe mon application en version 2 et que j’y rattache mon fichier d’extension main.1.com.infinitestudio.apkef.obb (via la fenêtre « Importer un nouveau fichier APK en version production » sur mon compte Google Play Developer), le téléchargement fonctionne mais lorsque je lance une vidéo cette erreur est déclenchée :

08-19 21:53:27.370: E/AndroidRuntime(12752): java.lang.NullPointerException
08-19 21:53:27.370: E/AndroidRuntime(12752): at com.android.vending.expansion.zipfile.APEZProvider.openAssetFile(APEZProvider.java:182)

donc si je veux faire une mise à jour de mon application, je suis obligé de lier un fichier d’extension qui se nomme main.2.com.infinitestudio.apkef.obb. Ce qui oblige l’utilisateur à télécharger le nouveau fichier d’extension alors qu’il n’y a que l’application qui a été mise à jour.

Si quelqu’un a une solution à ce problème je suis preneur.

Voilà le tutoriel sur l’APK Expansion Files est terminé, si vous avez des remarques ou des questions, n’hésitez pas à laisser un commentaire.

Rappel du lien vers les sources GitHub : https://github.com/Infinite-Studio/tuto-apk-expansion-files.


Share Button

36 pensées sur «APK Expansion Files pour applications Android»

  1. Bonjour très bon tutoriel,

    si on veut dézipper le fichier obb dans le dossier Android/data/ comment doit-on s’y prendre ?

    merci

  2. Bonjour, tu n’as pas besoin de dézipper le fichier obb car c’est le format utilisé pour stocker les ressouces externes. En revanche, si tu fais la manip à la main, il faut copier le fichier obb dans Android/data/nom_package_application/
  3. J’ai un autre soucis, si je supprime le fichier obb, quand l’application essaie de le télécharger, j’ai ce message « Echec du téléchargement, ressources non-disponibles », l’application est sur Google Play, j’ai bien renseigné fileVersion, fileSize, merci.
  4. Le fichier obb que tu viens de supprimer, tu l’as copié à la main directement dans le bon répertoire ou c’est le fichier qui est téléchargé depuis Google Play. Dans la console développeur sur Google Play, vérifie également que le fichier obb est bien lié à ton apk en cliquant dessus tu a une fenêtre qui s’ouvre dans laquelle tu as la description de l’apk qui a été importé et tu devrais avoir une ligne « Fichiers d’extension » avec le nom du fichier d’extension ainsi que son poids.
  5. il est bien lié,, c’est une version alpha de l’application que j’ai téléchargé via Google Play, le fichier est bien téléchargé donc il est bien sur les serveurs de Google, puis que j’ai supprimé.
  6. Si le fichier s’est bien téléchargé une première fois puis tu l’a supprimé et ensuite il ne se télécharge plus c’est quand même bizarre. Tu as essayé de refaire cette manip en désinstallant d’abord ton application puis en la réinstallant ?
    Dans la console développeur tu as téléchargé ton application en version alpha tu es sûr de ne pas en avoir une en version bêta ou en version production ?
  7. le fichier est telechargé avec l’application au départ, par contre quand c’est l’application pas moyen, j’ai un compte développeur, pas un compte marchand, c’est peut-être ça ?
  8. Non le compte marchand sert seulement dans le cas où ton application est payante. Quand tu dis que le fichier est téléchargé au départ, tu veux dire qu’il est téléchargé la première fois que tu lances l’application ? Ensuite c’est après avoir supprimé l’obb qu’il ne te le télécharge plus ?
  9. il est chargé avec l’application via Google Play, quand c’est l’application qui essaie de le télécharger ça ne marche jamais.
  10. est-ce que tu a des erreurs qui sont déclenchées dans les logs de ton appli lorsque tu télécharges l’obb ?
    Est-ce que tu es sûr d’avoir renseigné la bonne taille du fichier dans ta variable « fileSize » ?
  11. J’ai trouvé le soucis, j’avais activer le test de licence avec « NOT_LICENSED » dans les paramètres de mon compte Google Play.
    merci
  12. Bonjour j’ai une nouvelle question,

    j’encrypte mon fichier obb avec jobb.bat du sdk Android qui est dans le dossier tools du sdk android
    Citation:
    C:\Android\android-sdk\tools\jobb -d C:\Users\user\Desktop\myApp\folder\ -o C:\Users\user\Desktop\myApp\main.34452.com.app.myApp.obb -k myKey234 -pn com.app.myApp -pv 34452
    dans mon application j’utilise

    String obbDir = Environment.getExternalStorageDirectory().getAbsolutePath()+ »/Android/obb/ »+getPackageName()+ »/main.34452. »+getPackageName()+ ».obb »;
    StorageManager storage = (StorageManager) getSystemService(STORAGE_SERVICE);
    storage.mountObb(obbDir, « myKey234″, new OnObbStateChangeListener() {

    public void onObbStateChange(String path, int state) {
    if (state == OnObbStateChangeListener.ERROR_ALREADY_MOUNTED) {
    msg= « ERROR_ALREADY_MOUNTED!!!! »;
    }else if (state == OnObbStateChangeListener.ERROR_COULD_NOT_MOUNT) {
    msg= « ERROR_COULD_NOT_MOUNT!!!! »;
    }
    else if (state == OnObbStateChangeListener.ERROR_COULD_NOT_UNMOUNT) {
    msg= « ERROR_COULD_NOT_UNMOUNT!!!! »;
    }
    else if (state == OnObbStateChangeListener.ERROR_INTERNAL) {
    msg= « ERROR_INTERNAL!!!! »;
    }
    else if (state == OnObbStateChangeListener.ERROR_NOT_MOUNTED) {
    msg= « ERROR_NOT_MOUNTED!!!! »;
    }
    else if (state == OnObbStateChangeListener.ERROR_PERMISSION_DENIED) {
    msg= « ERROR_PERMISSION_DENIED!!!! »;
    }
    else if (state == OnObbStateChangeListener.MOUNTED) {
    msg= « MOUNTED!!!! »;
    }
    else if (state == OnObbStateChangeListener.UNMOUNTED) {
    msg= « UNMOUNTED!!!! »;
    }
    setContentView(getResourceId(« layout.obb »));
    TextView statusUnzip = (TextView) findViewById(getResourceId(« id.obb_status »));
    statusUnzip.setGravity(Gravity.CENTER);
    statusUnzip.setText(msg);
    }
    });

    quand je décrypte le fichier avec ma clé « storage.mountObb(obbDir, « myKey234″, new OnObbStateChangeListener() » ça ne fonctionne pas il me retourne « ERROR_COULD_NOT_MOUNT!!!! », sans clé ça fonctionne il me retourne « MOUNTED!!!! » ?

    merci

  13. Bonjour, j’arrive à reproduire ton problème lorsque je renseigne un mauvais nom de package dans la commande jobb, sinon ton code fonctionne correctement chez moi. Vérifie de bien renseigner les informations lors de l’exécution de la commande jobb. Sur quelle version d’Android testes tu ton code ?
  14. android 4.3
    si j’essaie d’extraire le fichier obb créé avec jobb
    C:\Android\android-sdk\tools\jobb -dump C:\Users\user\Desktop\myApp\main.34452.com.app.myApp.obb -d C:\Users\user\Desktop\myApp\ext\ -o C:\Users\user\Desktop\myApp\main.34452.com.app.myApp.obb -k myKey234

    j’ai ces erreurs
    Package Name: com.app.myApp
    Package Version: 1000031
    SALT: 4f52dd5eef01960d

    -2a75137d032f6c85935d83c760e36141
    LFN = video.mp4 / SFN = ShortName [/¤>???
    2 69 66 6c ]
    Alignment off reading from sector: 2203
    Partial read from sector: 2203
    Exception in thread « main » java.nio.BufferOverflowExc
    at java.nio.HeapByteBuffer.put(Unknown Source
    at java.nio.ByteBuffer.put(Unknown Source)
    at com.android.jobb.EncryptedBlockFile$Encryp
    ryptedSector(EncryptedBlockFile.java:292)
    at com.android.jobb.EncryptedBlockFile$Encryp
    cryptedBlockFile.java:137)
    at de.waldheinz.fs.util.FileDisk.read(FileDis
    at de.waldheinz.fs.fat.ClusterChain.readData(
    at de.waldheinz.fs.fat.FatFile.read(FatFile.j
    at com.android.jobb.Main.dumpDirectory(Main.j
    at com.android.jobb.Main.main(Main.java:315)

  15. Oui j’ai exactement la même erreur que toi lorsque je veux faire un extract des données contenues dans le obb. Je n’ai toujours pas trouver de solution à ce problème. En tout cas la solution m’intéresse ;)
  16. ça à l’air de fonctionner, est-ce que ça consomme de la mémoire de monter le fichier obb.
    Vaut-il mieux le refermer avec storage.unmountObb quand on a plus besoin d’une image quitte à refaire des storage.mountObb à chaque fois qu’on a besoin de nouvelles images, ou bien laisser le fichier obb monté jusqu’à ce que l’application soit fermée.
    En terme de performances, qu’elle est la meilleure façon de procéder ?
    merci
  17. Je ne sais pas si l’une ou l’autre solution a un gros impact sur les performances. J’aurais plus tendance à monter l’obb puis le laisser monter jusqu’à ce que l’application soit fermée. Par contre il faut quand même tester que le fichier est monté lorsque l’on veut accéder à une ressource.
  18. cet outil jobb est vraiment bizarre

    par exemple
    C:\Android\android-sdk\tools\jobb -d C:\Users\user\Desktop\myApp\folder\ -o C:\Users\user\Desktop\myApp\main.34452.com.app.myApp.obb -k myKey234 -pn com.app.myApp -pv 34452
    ne fonctionne pas

    alors que
    C:\Android\android-sdk\tools\jobb -d C:\Users\user\Desktop\myApp\folder\ -o C:\Users\user\Desktop\myApp\main.34452.com.app.myApp.obb -k « myKey234″ -pn com.app.myApp -pv 34452
    fonctionne (juste les guillemets qui change) mais pas pour tous les fichiers bien sûr

    jobb -d C:\Users\user\Desktop\myApp\folder\ -o C:\Users\user\Desktop\myApp\main.34452.com.app.myApp.obb -k « myKey234″ -pn com.app.myApp -pv 34452
    provoque une erreur à la compilation (C:\Android\android-sdk\tools\ est bien renseigné dans les variables d’environnement)

    ça me fatigue, je n’y comprends rien

  19. L’histoire des guillemets autour du mot de passe c’est vraiment étrange en revanche pour la commande jobb il te faut bien vérifier que le chemin vers le dossier « tools » est bien présent dans la variable système « PATH ». Si c’est déjà le cas essaye en enlevant le / à la fin du chemin.
  20. un autre soucis avec jobb impossible rentrer un chemin relatif

    jobb -d folder -o main.34452.com.app.myApp.obb -k « myKey234″ -pn com.app.myApp -pv 34452

    je suis obligé de rentrer jobb -d C:\Users\user\Desktop\myApp\folder\ -o C:\Users\user\Desktop\myApp\main.34452.com.app.myApp.obb -k « myKey234″ -pn com.app.myApp -pv 34452

  21. Slop: 0 Directory Overhead: 0
    Slop: 0 Directory Overhead: 0
    java.io.IOException: boot sector says there are 0 sectors per FAT
    at de.waldheinz.fs.fat.Fat.(Fat.java:112)
    at de.waldheinz.fs.fat.Fat.create(Fat.java:96)
    at de.waldheinz.fs.fat.SuperFloppyFormatter.format(SuperFloppyFormatter.
    java:236)
    at com.android.jobb.Main.main(Main.java:414)
  22. J’obtiens la même erreur que toi lorsque je renseigne un mauvais chemin par exemple, mais dans ton cas je pense plutôt que le problème vient du fait que tu entoure ton mot de passe avec des doubles quotes donc essaye de les enveler pour tester.
  23. Thanks ! Hi WordPress , Can not find /Android/obb/com.infinitestudio.apkef/main.1.com.infinitestudio.apkef.obb
  24. For the application to work, you must set the Google Play public key in file « APKEFTutoDownloaderService.java » and upload your application on Google Play and publish it. Otherwise for testing you can download zip file at the end of this article and put file « main.1.com.infinitestudio.apkef.obb » in your device.
  25. I’m exactly the same problem:
    com.android.vending.expansion.zipfile.APEZProvider.openAssetFile(APEZProvider.java:182)
    You found the solution ?
    Thanks!

Laisser un commentaire

Votre adresse de messagerie ne sera pas publiée. Les champs obligatoires sont indiqués avec *

Pour répondre à un commentaire, saisissez @ pour voir apparaître la liste des auteurs auxquels vous pouvez répondre.

Vous pouvez utiliser ces balises et attributs HTML : <a href="" title=""> <abbr title=""> <acronym title=""> <b> <blockquote cite=""> <cite> <code> <del datetime=""> <em> <i> <q cite=""> <strike> <strong>