[Android] Refactor AssetCopyService and the way we extract resources from the assets

This solves the following issues:
1. If user uninstall Dolphin and install it again the resources will
not be copied unless the user manually clear the app cache because we
are enabling the allowBackup flag in the manifest which will make the app
restore the settings saved in the shared prefernces including the flag
assetsCopied. This PR always copy the files everytime you open Dolphin.

2. If the AssetCopyService took long time and you tried to open the settings
screen or start a game the behaviour was not expected or the emulator will
crash, this PR make sure that whatever we add to the DirectoryInitializationService
or how long it will take will still work as expected by blocking both
the settings screen and the emulaion screen to wait untill all resources
needed are copied.

3. Better communication between the DirectoryInitializationService and the
UI screens using brocast messages.
This commit is contained in:
mahdihijazi 2017-12-17 01:09:55 +01:00
parent 9f9b4bc028
commit 1190afef51
13 changed files with 374 additions and 138 deletions

View File

@ -65,7 +65,7 @@
android:theme="@style/DolphinEmulationGamecube"/>
<service android:name=".services.AssetCopyService"/>
<service android:name=".services.DirectoryInitializationService"/>
<provider
android:name=".model.GameProvider"

View File

@ -1,10 +1,12 @@
package org.dolphinemu.dolphinemu.fragments;
import android.content.Context;
import android.content.IntentFilter;
import android.content.SharedPreferences;
import android.os.Bundle;
import android.preference.PreferenceManager;
import android.support.v4.app.Fragment;
import android.support.v4.content.LocalBroadcastManager;
import android.view.LayoutInflater;
import android.view.Surface;
import android.view.SurfaceHolder;
@ -12,13 +14,19 @@ import android.view.SurfaceView;
import android.view.View;
import android.view.ViewGroup;
import android.widget.Button;
import android.widget.Toast;
import org.dolphinemu.dolphinemu.NativeLibrary;
import org.dolphinemu.dolphinemu.R;
import org.dolphinemu.dolphinemu.activities.EmulationActivity;
import org.dolphinemu.dolphinemu.overlay.InputOverlay;
import org.dolphinemu.dolphinemu.services.DirectoryInitializationService;
import org.dolphinemu.dolphinemu.services.DirectoryInitializationService.DirectoryInitializationState;
import org.dolphinemu.dolphinemu.utils.DirectoryStateReceiver;
import org.dolphinemu.dolphinemu.utils.Log;
import rx.functions.Action1;
public final class EmulationFragment extends Fragment implements SurfaceHolder.Callback
{
private static final String KEY_GAMEPATH = "gamepath";
@ -29,6 +37,8 @@ public final class EmulationFragment extends Fragment implements SurfaceHolder.C
private EmulationState mEmulationState;
private DirectoryStateReceiver directoryStateReceiver;
public static EmulationFragment newInstance(String gamePath)
{
@ -108,12 +118,25 @@ public final class EmulationFragment extends Fragment implements SurfaceHolder.C
public void onResume()
{
super.onResume();
mEmulationState.run();
if (DirectoryInitializationService.areDolphinDirectoriesReady())
{
mEmulationState.run();
}
else
{
setupDolphinDirectoriesThenStartEmulation();
}
}
@Override
public void onPause()
{
if (directoryStateReceiver != null)
{
LocalBroadcastManager.getInstance(getActivity()).unregisterReceiver(directoryStateReceiver);
directoryStateReceiver = null;
}
mEmulationState.pause();
super.onPause();
}
@ -125,6 +148,27 @@ public final class EmulationFragment extends Fragment implements SurfaceHolder.C
super.onDetach();
}
private void setupDolphinDirectoriesThenStartEmulation() {
IntentFilter statusIntentFilter = new IntentFilter(
DirectoryInitializationService.BROADCAST_ACTION);
directoryStateReceiver =
new DirectoryStateReceiver(directoryInitializationState -> {
if (directoryInitializationState == DirectoryInitializationState.DOLPHIN_DIRECTORIES_INITIALIZED) {
mEmulationState.run();
} else if (directoryInitializationState == DirectoryInitializationState.EXTERNAL_STORAGE_PERMISSION_NEEDED) {
Toast.makeText(getContext(), R.string.write_permission_needed, Toast.LENGTH_SHORT)
.show();
}
});
// Registers the DirectoryStateReceiver and its intent filters
LocalBroadcastManager.getInstance(getActivity()).registerReceiver(
directoryStateReceiver,
statusIntentFilter);
DirectoryInitializationService.startService(getActivity());
}
public void toggleInputOverlayVisibility()
{
SharedPreferences.Editor editor = mPreferences.edit();

View File

@ -1,112 +0,0 @@
/**
* Copyright 2014 Dolphin Emulator Project
* Licensed under GPLv2+
* Refer to the license.txt file included.
*/
package org.dolphinemu.dolphinemu.services;
import android.app.IntentService;
import android.content.Intent;
import android.content.SharedPreferences;
import android.preference.PreferenceManager;
import org.dolphinemu.dolphinemu.NativeLibrary;
import org.dolphinemu.dolphinemu.utils.Log;
import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
/**
* A service that spawns its own thread in order to copy several binary and shader files
* from the Dolphin APK to the external file system.
*/
public final class AssetCopyService extends IntentService
{
public AssetCopyService()
{
// Superclass constructor is called to name the thread on which this service executes.
super("AssetCopyService");
}
@Override
protected void onHandleIntent(Intent intent)
{
String BaseDir = NativeLibrary.GetUserDirectory();
String ConfigDir = BaseDir + File.separator + "Config";
// Copy assets if needed
NativeLibrary.CreateUserFolders();
copyAssetFolder("GC", BaseDir + File.separator + "GC", false);
copyAssetFolder("Shaders", BaseDir + File.separator + "Shaders", false);
copyAssetFolder("Wii", BaseDir + File.separator + "Wii", false);
// Always copy over the GCPad config in case of change or corruption.
// Not a user configurable file.
copyAsset("GCPadNew.ini", ConfigDir + File.separator + "GCPadNew.ini", true);
copyAsset("WiimoteNew.ini", ConfigDir + File.separator + "WiimoteNew.ini", true);
// Record the fact that we've done this before, so we don't do it on every launch.
SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(this);
SharedPreferences.Editor editor = preferences.edit();
editor.putBoolean("assetsCopied", true);
editor.apply();
}
private void copyAsset(String asset, String output, Boolean overwrite)
{
Log.verbose("[AssetCopyService] Copying File " + asset + " to " + output);
InputStream in;
OutputStream out;
try
{
File file = new File(output);
if(!file.exists() || overwrite)
{
in = getAssets().open(asset);
out = new FileOutputStream(output);
copyFile(in, out);
in.close();
out.close();
}
}
catch (IOException e)
{
Log.error("[AssetCopyService] Failed to copy asset file: " + asset + e.getMessage());
}
}
private void copyAssetFolder(String assetFolder, String outputFolder, Boolean overwrite)
{
Log.verbose("[AssetCopyService] Copying Folder " + assetFolder + " to " + outputFolder);
try
{
for (String file : getAssets().list(assetFolder))
{
copyAssetFolder(assetFolder + File.separator + file, outputFolder + File.separator + file, overwrite);
copyAsset(assetFolder + File.separator + file, outputFolder + File.separator + file, overwrite);
}
}
catch (IOException e)
{
Log.error("[AssetCopyService] Failed to copy asset folder: " + assetFolder + e.getMessage());
}
}
private void copyFile(InputStream in, OutputStream out) throws IOException
{
byte[] buffer = new byte[1024];
int read;
while ((read = in.read(buffer)) != -1)
{
out.write(buffer, 0, read);
}
}
}

View File

@ -0,0 +1,154 @@
/**
* Copyright 2014 Dolphin Emulator Project
* Licensed under GPLv2+
* Refer to the license.txt file included.
*/
package org.dolphinemu.dolphinemu.services;
import android.app.IntentService;
import android.content.Context;
import android.content.Intent;
import android.support.v4.content.LocalBroadcastManager;
import org.dolphinemu.dolphinemu.NativeLibrary;
import org.dolphinemu.dolphinemu.utils.Log;
import org.dolphinemu.dolphinemu.utils.PermissionsHandler;
import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
/**
* A service that spawns its own thread in order to copy several binary and shader files
* from the Dolphin APK to the external file system.
*/
public final class DirectoryInitializationService extends IntentService
{
public static final String BROADCAST_ACTION = "org.dolphinemu.dolphinemu.BROADCAST";
public static final String EXTRA_STATE = "directoryState";
private static DirectoryInitializationState directoryState = null;
public enum DirectoryInitializationState
{
DOLPHIN_DIRECTORIES_INITIALIZED,
EXTERNAL_STORAGE_PERMISSION_NEEDED
}
public DirectoryInitializationService()
{
// Superclass constructor is called to name the thread on which this service executes.
super("DirectoryInitializationService");
}
public static void startService(Context context)
{
Intent intent = new Intent(context, DirectoryInitializationService.class);
context.startService(intent);
}
@Override
protected void onHandleIntent(Intent intent)
{
if (directoryState == DirectoryInitializationState.DOLPHIN_DIRECTORIES_INITIALIZED)
{
sendBroadcastState(DirectoryInitializationState.DOLPHIN_DIRECTORIES_INITIALIZED);
}
else if (PermissionsHandler.hasWriteAccess(this))
{
initDolphinDirectories();
directoryState = DirectoryInitializationState.DOLPHIN_DIRECTORIES_INITIALIZED;
sendBroadcastState(directoryState);
}
else
{
sendBroadcastState(DirectoryInitializationState.EXTERNAL_STORAGE_PERMISSION_NEEDED);
}
}
private void initDolphinDirectories()
{
String BaseDir = NativeLibrary.GetUserDirectory();
String ConfigDir = BaseDir + File.separator + "Config";
// Copy assets if needed
NativeLibrary.CreateUserFolders();
copyAssetFolder("GC", BaseDir + File.separator + "GC", false);
copyAssetFolder("Shaders", BaseDir + File.separator + "Shaders", false);
copyAssetFolder("Wii", BaseDir + File.separator + "Wii", false);
// Always copy over the GCPad config in case of change or corruption.
// Not a user configurable file.
copyAsset("GCPadNew.ini", ConfigDir + File.separator + "GCPadNew.ini", true);
copyAsset("WiimoteNew.ini", ConfigDir + File.separator + "WiimoteNew.ini", true);
}
public static boolean areDolphinDirectoriesReady()
{
return directoryState == DirectoryInitializationState.DOLPHIN_DIRECTORIES_INITIALIZED;
}
private void sendBroadcastState(DirectoryInitializationState state)
{
Intent localIntent =
new Intent(BROADCAST_ACTION)
.putExtra(EXTRA_STATE, state);
LocalBroadcastManager.getInstance(this).sendBroadcast(localIntent);
}
private void copyAsset(String asset, String output, Boolean overwrite)
{
Log.verbose("[DirectoryInitializationService] Copying File " + asset + " to " + output);
InputStream in;
OutputStream out;
try
{
File file = new File(output);
if (!file.exists() || overwrite)
{
in = getAssets().open(asset);
out = new FileOutputStream(output);
copyFile(in, out);
in.close();
out.close();
}
}
catch (IOException e)
{
Log.error("[DirectoryInitializationService] Failed to copy asset file: " + asset + e.getMessage());
}
}
private void copyAssetFolder(String assetFolder, String outputFolder, Boolean overwrite)
{
Log.verbose("[DirectoryInitializationService] Copying Folder " + assetFolder + " to " + outputFolder);
try
{
for (String file : getAssets().list(assetFolder))
{
copyAssetFolder(assetFolder + File.separator + file, outputFolder + File.separator + file, overwrite);
copyAsset(assetFolder + File.separator + file, outputFolder + File.separator + file, overwrite);
}
}
catch (IOException e)
{
Log.error("[DirectoryInitializationService] Failed to copy asset folder: " + assetFolder + e.getMessage());
}
}
private void copyFile(InputStream in, OutputStream out) throws IOException
{
byte[] buffer = new byte[1024];
int read;
while ((read = in.read(buffer)) != -1)
{
out.write(buffer, 0, read);
}
}
}

View File

@ -20,6 +20,7 @@ import org.dolphinemu.dolphinemu.R;
import org.dolphinemu.dolphinemu.activities.AddDirectoryActivity;
import org.dolphinemu.dolphinemu.adapters.PlatformPagerAdapter;
import org.dolphinemu.dolphinemu.model.GameProvider;
import org.dolphinemu.dolphinemu.services.DirectoryInitializationService;
import org.dolphinemu.dolphinemu.ui.platform.Platform;
import org.dolphinemu.dolphinemu.ui.platform.PlatformGamesView;
import org.dolphinemu.dolphinemu.ui.settings.SettingsActivity;
@ -154,7 +155,7 @@ public final class MainActivity extends AppCompatActivity implements MainView
switch (requestCode) {
case PermissionsHandler.REQUEST_CODE_WRITE_PERMISSION:
if (grantResults[0] == PackageManager.PERMISSION_GRANTED) {
StartupHandler.copyAssetsIfNeeded(this);
DirectoryInitializationService.startService(this);
PlatformPagerAdapter platformPagerAdapter = new PlatformPagerAdapter(
getSupportFragmentManager(), this);

View File

@ -28,6 +28,7 @@ import org.dolphinemu.dolphinemu.adapters.GameRowPresenter;
import org.dolphinemu.dolphinemu.adapters.SettingsRowPresenter;
import org.dolphinemu.dolphinemu.model.Game;
import org.dolphinemu.dolphinemu.model.TvSettingsItem;
import org.dolphinemu.dolphinemu.services.DirectoryInitializationService;
import org.dolphinemu.dolphinemu.ui.platform.Platform;
import org.dolphinemu.dolphinemu.ui.settings.SettingsActivity;
import org.dolphinemu.dolphinemu.utils.PermissionsHandler;
@ -157,7 +158,7 @@ public final class TvMainActivity extends FragmentActivity implements MainView
switch (requestCode) {
case PermissionsHandler.REQUEST_CODE_WRITE_PERMISSION:
if (grantResults[0] == PackageManager.PERMISSION_GRANTED) {
StartupHandler.copyAssetsIfNeeded(this);
DirectoryInitializationService.startService(this);
loadGames();
} else {
Toast.makeText(this, R.string.write_permission_needed, Toast.LENGTH_SHORT)

View File

@ -1,9 +1,12 @@
package org.dolphinemu.dolphinemu.ui.settings;
import android.app.ProgressDialog;
import android.content.Context;
import android.content.Intent;
import android.content.IntentFilter;
import android.os.Bundle;
import android.support.v4.app.FragmentTransaction;
import android.support.v4.content.LocalBroadcastManager;
import android.support.v7.app.AppCompatActivity;
import android.view.Menu;
import android.view.MenuInflater;
@ -12,6 +15,8 @@ import android.widget.Toast;
import org.dolphinemu.dolphinemu.R;
import org.dolphinemu.dolphinemu.model.settings.SettingSection;
import org.dolphinemu.dolphinemu.services.DirectoryInitializationService;
import org.dolphinemu.dolphinemu.utils.DirectoryStateReceiver;
import java.util.ArrayList;
import java.util.HashMap;
@ -22,6 +27,8 @@ public final class SettingsActivity extends AppCompatActivity implements Setting
private static final String FRAGMENT_TAG = "settings";
private SettingsActivityPresenter mPresenter = new SettingsActivityPresenter(this);
private ProgressDialog dialog;
public static void launch(Context context, String menuTag)
{
Intent settings = new Intent(context, SettingsActivity.class);
@ -65,6 +72,13 @@ public final class SettingsActivity extends AppCompatActivity implements Setting
mPresenter.saveState(outState);
}
@Override
protected void onStart()
{
super.onStart();
mPresenter.onStart();
}
/**
* If this is called, the user has left the settings screen (potentially through the
* home button) and will expect their changes to be persisted. So we kick off an
@ -106,6 +120,47 @@ public final class SettingsActivity extends AppCompatActivity implements Setting
transaction.commit();
}
@Override
public void startDirectoryInitializationService(DirectoryStateReceiver receiver, IntentFilter filter)
{
LocalBroadcastManager.getInstance(this).registerReceiver(
receiver,
filter);
DirectoryInitializationService.startService(this);
}
@Override
public void stopListeningToDirectoryInitializationService(DirectoryStateReceiver receiver)
{
LocalBroadcastManager.getInstance(this).unregisterReceiver(receiver);
}
@Override
public void showLoading()
{
if (dialog == null)
{
dialog = new ProgressDialog(this);
dialog.setMessage(getString(R.string.load_settings));
dialog.setIndeterminate(true);
}
dialog.show();
}
@Override
public void hideLoading()
{
dialog.dismiss();
}
@Override
public void showPermissionNeededHint()
{
Toast.makeText(this, R.string.write_permission_needed, Toast.LENGTH_SHORT)
.show();
}
@Override
public HashMap<String, SettingSection> getSettings(int file)
{

View File

@ -1,15 +1,20 @@
package org.dolphinemu.dolphinemu.ui.settings;
import android.content.IntentFilter;
import android.os.Bundle;
import org.dolphinemu.dolphinemu.R;
import org.dolphinemu.dolphinemu.model.settings.SettingSection;
import org.dolphinemu.dolphinemu.services.DirectoryInitializationService;
import org.dolphinemu.dolphinemu.utils.DirectoryStateReceiver;
import org.dolphinemu.dolphinemu.utils.Log;
import org.dolphinemu.dolphinemu.utils.SettingsFile;
import java.util.ArrayList;
import java.util.HashMap;
import rx.functions.Action1;
public final class SettingsActivityPresenter
{
private static final String KEY_SHOULD_SAVE = "should_save";
@ -22,6 +27,10 @@ public final class SettingsActivityPresenter
private boolean mShouldSave;
private DirectoryStateReceiver directoryStateReceiver;
private String menuTag;
public SettingsActivityPresenter(SettingsActivityView view)
{
mView = view;
@ -31,12 +40,10 @@ public final class SettingsActivityPresenter
{
if (savedInstanceState == null)
{
mView.showSettingsFragment(menuTag, false);
mSettings.add(SettingsFile.SETTINGS_DOLPHIN, SettingsFile.readFile(SettingsFile.FILE_NAME_DOLPHIN, mView));
mSettings.add(SettingsFile.SETTINGS_GFX, SettingsFile.readFile(SettingsFile.FILE_NAME_GFX, mView));
mSettings.add(SettingsFile.SETTINGS_WIIMOTE, SettingsFile.readFile(SettingsFile.FILE_NAME_WIIMOTE, mView));
mView.onSettingsFileLoaded(mSettings);
this.menuTag = menuTag;
}
else
{
@ -44,6 +51,41 @@ public final class SettingsActivityPresenter
}
}
public void onStart()
{
prepareDolphinDirectoriesIfNeeded();
}
void loadSettingsUI()
{
mView.showSettingsFragment(menuTag, false);
mView.onSettingsFileLoaded(mSettings);
}
private void prepareDolphinDirectoriesIfNeeded()
{
if (DirectoryInitializationService.areDolphinDirectoriesReady()) {
loadSettingsUI();
} else {
mView.showLoading();
IntentFilter statusIntentFilter = new IntentFilter(
DirectoryInitializationService.BROADCAST_ACTION);
directoryStateReceiver =
new DirectoryStateReceiver(directoryInitializationState -> {
if (directoryInitializationState == DirectoryInitializationService.DirectoryInitializationState.DOLPHIN_DIRECTORIES_INITIALIZED) {
mView.hideLoading();
loadSettingsUI();
} else if (directoryInitializationState == DirectoryInitializationService.DirectoryInitializationState.EXTERNAL_STORAGE_PERMISSION_NEEDED) {
mView.showPermissionNeededHint();
mView.hideLoading();
}
});
mView.startDirectoryInitializationService(directoryStateReceiver, statusIntentFilter);
}
}
public void setSettings(ArrayList<HashMap<String, SettingSection>> settings)
{
mSettings = settings;
@ -56,6 +98,12 @@ public final class SettingsActivityPresenter
public void onStop(boolean finishing)
{
if (directoryStateReceiver != null)
{
mView.stopListeningToDirectoryInitializationService(directoryStateReceiver);
directoryStateReceiver = null;
}
if (mSettings != null && finishing && mShouldSave)
{
Log.debug("[SettingsActivity] Settings activity stopping. Saving settings to INI...");

View File

@ -1,6 +1,9 @@
package org.dolphinemu.dolphinemu.ui.settings;
import android.content.IntentFilter;
import org.dolphinemu.dolphinemu.model.settings.SettingSection;
import org.dolphinemu.dolphinemu.utils.DirectoryStateReceiver;
import java.util.ArrayList;
import java.util.HashMap;
@ -99,4 +102,34 @@ public interface SettingsActivityView
* @param value New setting for the extension.
*/
void onExtensionSettingChanged(String key, int value);
/**
* Show loading dialog while loading the settings
*/
void showLoading();
/**
* Hide the loading the dialog
*/
void hideLoading();
/**
* Show a hint to the user that the app needs write to external storage access
*/
void showPermissionNeededHint();
/**
* Start the DirectoryInitializationService and listen for the result.
*
* @param receiver the broadcast receiver for the DirectoryInitializationService
* @param filter the Intent broadcasts to be received.
*/
void startDirectoryInitializationService(DirectoryStateReceiver receiver, IntentFilter filter);
/**
* Stop listening to the DirectoryInitializationService.
*
* @param receiver The broadcast receiver to unregister.
*/
void stopListeningToDirectoryInitializationService(DirectoryStateReceiver receiver);
}

View File

@ -0,0 +1,24 @@
package org.dolphinemu.dolphinemu.utils;
import android.content.BroadcastReceiver;
import android.content.Context;
import android.content.Intent;
import org.dolphinemu.dolphinemu.services.DirectoryInitializationService;
import org.dolphinemu.dolphinemu.services.DirectoryInitializationService.DirectoryInitializationState;
import rx.functions.Action1;
public class DirectoryStateReceiver extends BroadcastReceiver {
Action1<DirectoryInitializationState> callback;
public DirectoryStateReceiver(Action1<DirectoryInitializationState> callback) {
this.callback = callback;
}
@Override
public void onReceive(Context context, Intent intent)
{
DirectoryInitializationState state = (DirectoryInitializationState) intent.getSerializableExtra(DirectoryInitializationService.EXTRA_STATE);
callback.call(state);
}
}

View File

@ -2,6 +2,7 @@ package org.dolphinemu.dolphinemu.utils;
import android.annotation.TargetApi;
import android.app.AlertDialog;
import android.content.Context;
import android.content.DialogInterface;
import android.content.pm.PackageManager;
import android.os.Build;
@ -40,9 +41,9 @@ public class PermissionsHandler {
return true;
}
public static boolean hasWriteAccess(FragmentActivity activity) {
public static boolean hasWriteAccess(Context context) {
if (android.os.Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
int hasWritePermission = ContextCompat.checkSelfPermission(activity, WRITE_EXTERNAL_STORAGE);
int hasWritePermission = ContextCompat.checkSelfPermission(context, WRITE_EXTERNAL_STORAGE);
return hasWritePermission == PackageManager.PERMISSION_GRANTED;
}

View File

@ -1,15 +1,13 @@
package org.dolphinemu.dolphinemu.utils;
import android.content.Intent;
import android.content.SharedPreferences;
import android.os.Bundle;
import android.preference.PreferenceManager;
import android.support.v4.app.FragmentActivity;
import android.text.TextUtils;
import org.dolphinemu.dolphinemu.NativeLibrary;
import org.dolphinemu.dolphinemu.activities.EmulationActivity;
import org.dolphinemu.dolphinemu.services.AssetCopyService;
import org.dolphinemu.dolphinemu.services.DirectoryInitializationService;
public final class StartupHandler
{
@ -17,9 +15,8 @@ public final class StartupHandler
{
NativeLibrary.SetUserDirectory(""); // Auto-Detect
// Only perform these extensive copy operations once.
if (PermissionsHandler.checkWritePermission(parent)) {
copyAssetsIfNeeded(parent);
DirectoryInitializationService.startService(parent);
}
Intent intent = parent.getIntent();
@ -45,16 +42,4 @@ public final class StartupHandler
}
return false;
}
public static void copyAssetsIfNeeded(FragmentActivity parent) {
SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(parent);
boolean assetsCopied = preferences.getBoolean("assetsCopied", false);
if (!assetsCopied)
{
// Copy assets into appropriate locations.
Intent copyAssets = new Intent(parent, AssetCopyService.class);
parent.startService(copyAssets);
}
}
}

View File

@ -244,4 +244,6 @@
<string name="header_controllers">Controllers</string>
<string name="write_permission_needed">You need to allow write access to external storage for the emulator to work</string>
<string name="load_settings">Loading Settings...</string>
</resources>