GameActivity.java 24 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645
  1. /**
  2. * Copyright (c) 2006-2023 LOVE Development Team
  3. *
  4. * This software is provided 'as-is', without any express or implied
  5. * warranty. In no event will the authors be held liable for any damages
  6. * arising from the use of this software.
  7. *
  8. * Permission is granted to anyone to use this software for any purpose,
  9. * including commercial applications, and to alter it and redistribute it
  10. * freely, subject to the following restrictions:
  11. *
  12. * 1. The origin of this software must not be misrepresented; you must not
  13. * claim that you wrote the original software. If you use this software
  14. * in a product, an acknowledgment in the product documentation would be
  15. * appreciated but is not required.
  16. * 2. Altered source versions must be plainly marked as such, and must not be
  17. * misrepresented as being the original software.
  18. * 3. This notice may not be removed or altered from any source distribution.
  19. **/
  20. package org.love2d.android;
  21. import org.libsdl.app.SDLActivity;
  22. import java.io.BufferedOutputStream;
  23. import java.io.File;
  24. import java.io.FileNotFoundException;
  25. import java.io.FileOutputStream;
  26. import java.io.IOException;
  27. import java.io.InputStream;
  28. import java.util.ArrayList;
  29. import java.util.HashMap;
  30. import java.util.List;
  31. import java.util.Map;
  32. import android.Manifest;
  33. import android.app.AlertDialog;
  34. import android.content.Context;
  35. import android.content.DialogInterface;
  36. import android.content.Intent;
  37. import android.content.pm.ApplicationInfo;
  38. import android.content.res.AssetManager;
  39. import android.media.AudioManager;
  40. import android.net.Uri;
  41. import android.os.Bundle;
  42. import android.os.Environment;
  43. import android.os.Vibrator;
  44. import android.util.Log;
  45. import android.util.DisplayMetrics;
  46. import android.view.*;
  47. import android.content.pm.PackageManager;
  48. import androidx.annotation.Keep;
  49. import androidx.core.app.ActivityCompat;
  50. public class GameActivity extends SDLActivity {
  51. private static DisplayMetrics metrics = null;
  52. private static String gamePath = "";
  53. private static Vibrator vibrator = null;
  54. protected final int[] externalStorageRequestDummy = new int[1];
  55. protected final int[] recordAudioRequestDummy = new int[1];
  56. public static final int EXTERNAL_STORAGE_REQUEST_CODE = 2;
  57. public static final int RECORD_AUDIO_REQUEST_CODE = 3;
  58. private static boolean immersiveActive = false;
  59. private static boolean needToCopyGameInArchive = false;
  60. private boolean storagePermissionUnnecessary = false;
  61. private boolean shortEdgesMode = false;
  62. public boolean embed = false;
  63. public int safeAreaTop = 0;
  64. public int safeAreaLeft = 0;
  65. public int safeAreaBottom = 0;
  66. public int safeAreaRight = 0;
  67. private static native void nativeSetDefaultStreamValues(int sampleRate, int framesPerBurst);
  68. @Override
  69. protected String[] getLibraries() {
  70. return new String[] {
  71. "c++_shared",
  72. "mpg123",
  73. "openal",
  74. "love",
  75. };
  76. }
  77. @Override
  78. protected String getMainSharedObject() {
  79. String[] libs = getLibraries();
  80. String libname = "lib" + libs[libs.length - 1] + ".so";
  81. // Since Lollipop, you can simply pass "libname.so" to dlopen
  82. // and it will resolve correct paths and load correct library.
  83. // This is mandatory for extractNativeLibs=false support in
  84. // Marshmallow.
  85. if (android.os.Build.VERSION.SDK_INT >= 21) {
  86. return libname;
  87. } else {
  88. return getContext().getApplicationInfo().nativeLibraryDir + "/" + libname;
  89. }
  90. }
  91. @Override
  92. protected void onCreate(Bundle savedInstanceState) {
  93. Log.d("GameActivity", "started");
  94. int res = checkCallingOrSelfPermission(Manifest.permission.VIBRATE);
  95. if (res == PackageManager.PERMISSION_GRANTED) {
  96. vibrator = (Vibrator) getSystemService(Context.VIBRATOR_SERVICE);
  97. } else {
  98. Log.d("GameActivity", "Vibration disabled: could not get vibration permission.");
  99. }
  100. // These 2 variables must be reset or it will use the existing value.
  101. gamePath = "";
  102. storagePermissionUnnecessary = false;
  103. embed = getResources().getBoolean(R.bool.embed);
  104. needToCopyGameInArchive = embed;
  105. if (!embed) {
  106. Intent intent = getIntent();
  107. handleIntent(intent);
  108. intent.setData(null);
  109. }
  110. super.onCreate(savedInstanceState);
  111. metrics = getResources().getDisplayMetrics();
  112. // Set low-latency audio values
  113. nativeSetDefaultStreamValues(getAudioFreq(), getAudioSMP());
  114. if (android.os.Build.VERSION.SDK_INT >= 28) {
  115. getWindow().getAttributes().layoutInDisplayCutoutMode = WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_NEVER;
  116. shortEdgesMode = false;
  117. }
  118. }
  119. @Override
  120. protected void onNewIntent(Intent intent) {
  121. Log.d("GameActivity", "onNewIntent() with " + intent);
  122. if (!embed) {
  123. handleIntent(intent);
  124. resetNative();
  125. startNative();
  126. }
  127. }
  128. protected void handleIntent(Intent intent) {
  129. Uri game = intent.getData();
  130. if (!embed && game != null) {
  131. String scheme = game.getScheme();
  132. String path = game.getPath();
  133. // If we have a game via the intent data we we try to figure out how we have to load it. We
  134. // support the following variations:
  135. // * a main.lua file: set gamePath to the directory containing main.lua
  136. // * otherwise: set gamePath to the file
  137. if (scheme.equals("file")) {
  138. Log.d("GameActivity", "Received file:// intent with path: " + path);
  139. // If we were given the path of a main.lua then use its
  140. // directory. Otherwise use full path.
  141. List<String> path_segments = game.getPathSegments();
  142. if (path_segments.get(path_segments.size() - 1).equals("main.lua")) {
  143. gamePath = path.substring(0, path.length() - "main.lua".length());
  144. } else {
  145. gamePath = path;
  146. }
  147. } else if (scheme.equals("content")) {
  148. Log.d("GameActivity", "Received content:// intent with path: " + path);
  149. try {
  150. String filename = "game.love";
  151. String[] pathSegments = path.split("/");
  152. if (pathSegments.length > 0) {
  153. filename = pathSegments[pathSegments.length - 1];
  154. }
  155. // Sanitize filename to prevent PhysFS complaining later.
  156. filename = filename.replaceAll("[^a-zA-Z0-9_\\\\-\\\\.]", "_");
  157. String destination_file = this.getCacheDir().getPath() + "/" + filename;
  158. InputStream data = getContentResolver().openInputStream(game);
  159. // copyAssetFile automatically closes the InputStream
  160. if (copyAssetFile(data, destination_file)) {
  161. gamePath = destination_file;
  162. storagePermissionUnnecessary = true;
  163. }
  164. } catch (Exception e) {
  165. Log.d("GameActivity", "could not read content uri " + game.toString() + ": " + e.getMessage());
  166. }
  167. } else {
  168. Log.e("GameActivity", "Unsupported scheme: '" + game.getScheme() + "'.");
  169. AlertDialog.Builder alert_dialog = new AlertDialog.Builder(this);
  170. alert_dialog.setMessage("Could not load LÖVE game '" + path
  171. + "' as it uses unsupported scheme '" + game.getScheme()
  172. + "'. Please contact the developer.");
  173. alert_dialog.setTitle("LÖVE for Android Error");
  174. alert_dialog.setPositiveButton("Exit",
  175. new DialogInterface.OnClickListener() {
  176. @Override
  177. public void onClick(DialogInterface dialog, int id) {
  178. finish();
  179. }
  180. });
  181. alert_dialog.setCancelable(false);
  182. alert_dialog.create().show();
  183. }
  184. }
  185. Log.d("GameActivity", "new gamePath: " + gamePath);
  186. }
  187. private void copyGameInsideArchive() {
  188. try {
  189. // If we have a game.love in our assets folder copy it to the cache folder
  190. // so that we can load it from native LÖVE code
  191. AssetManager assetManager = getAssets();
  192. InputStream gameStream = assetManager.open("game.love");
  193. String destinationFile = this.getCacheDir().getPath() + "/game.love";
  194. if (copyAssetFile(gameStream, destinationFile))
  195. gamePath = destinationFile;
  196. else
  197. gamePath = "game.love";
  198. storagePermissionUnnecessary = true;
  199. } catch (IOException e) {
  200. // There's no game.love in our assets
  201. Log.d("GameActivity", "Could not open game.love from assets: " + e.getMessage());
  202. }
  203. }
  204. protected void checkLovegameFolder() {
  205. // If no game.love was found and embed flavor is not used, fall back to the game in
  206. // <external storage>/Android/data/<package name>/games/lovegame
  207. if (!embed) {
  208. Log.d("GameActivity", "fallback to lovegame folder");
  209. File ext = getExternalFilesDir("games");
  210. if ((new File(ext, "/lovegame/main.lua")).exists()) {
  211. gamePath = ext.getPath() + "/lovegame/";
  212. storagePermissionUnnecessary = true;
  213. } else if (android.os.Build.VERSION.SDK_INT <= 28) {
  214. // Try to fallback to /sdcard/lovegame in Android 9 and earlier too.
  215. if (hasExternalStoragePermission()) {
  216. ext = Environment.getExternalStorageDirectory();
  217. if ((new File(ext, "/lovegame/main.lua")).exists()) {
  218. gamePath = ext.getPath() + "/lovegame/";
  219. storagePermissionUnnecessary = false;
  220. }
  221. } else {
  222. Log.d("GameActivity", "Cannot load game from /sdcard/lovegame: permission not granted");
  223. }
  224. }
  225. Log.d("GameActivity", "lovegame directory: " + gamePath);
  226. }
  227. }
  228. @Override
  229. protected void onDestroy() {
  230. if (vibrator != null) {
  231. Log.d("GameActivity", "Cancelling vibration");
  232. vibrator.cancel();
  233. }
  234. super.onDestroy();
  235. }
  236. @Override
  237. protected void onPause() {
  238. if (vibrator != null) {
  239. Log.d("GameActivity", "Cancelling vibration");
  240. vibrator.cancel();
  241. }
  242. super.onPause();
  243. }
  244. @Override
  245. public void onResume() {
  246. super.onResume();
  247. }
  248. @Keep
  249. public void setImmersiveMode(boolean immersive_mode) {
  250. if (android.os.Build.VERSION.SDK_INT >= 28) {
  251. getWindow().getAttributes().layoutInDisplayCutoutMode = immersive_mode ?
  252. WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES :
  253. WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_NEVER;
  254. shortEdgesMode = immersive_mode;
  255. }
  256. immersiveActive = immersive_mode;
  257. }
  258. @Keep
  259. public boolean getImmersiveMode() {
  260. return immersiveActive;
  261. }
  262. @Keep
  263. public static String getGamePath() {
  264. GameActivity self = (GameActivity) mSingleton; // use SDL provided one
  265. Log.d("GameActivity", "called getGamePath(), game path = " + gamePath);
  266. if (gamePath.length() > 0) {
  267. if (self.storagePermissionUnnecessary || self.hasExternalStoragePermission()) {
  268. return gamePath;
  269. } else {
  270. Log.d("GameActivity", "cannot open game " + gamePath + ": no external storage permission given!");
  271. }
  272. } else if (needToCopyGameInArchive) {
  273. self.copyGameInsideArchive();
  274. } else {
  275. self.checkLovegameFolder();
  276. }
  277. return gamePath;
  278. }
  279. public static DisplayMetrics getMetrics() {
  280. return metrics;
  281. }
  282. @Keep
  283. public static void vibrate(double seconds) {
  284. if (vibrator != null) {
  285. vibrator.vibrate((long) (seconds * 1000.));
  286. }
  287. }
  288. @Keep
  289. public static boolean openURLFromLOVE(String url) {
  290. Log.d("GameActivity", "opening url = " + url);
  291. return openURL(url) == 0;
  292. }
  293. /**
  294. * Copies a given file from the assets folder to the destination.
  295. *
  296. * @return true if successful
  297. */
  298. boolean copyAssetFile(InputStream source, String destinationFileName) {
  299. boolean success = false;
  300. BufferedOutputStream destination = null;
  301. try {
  302. destination = new BufferedOutputStream(new FileOutputStream(destinationFileName, false));
  303. } catch (IOException e) {
  304. Log.d("GameActivity", "Could not open destination file: " + e.getMessage());
  305. }
  306. // perform the copying
  307. int chunk_read;
  308. int bytes_written = 0;
  309. assert (source != null && destination != null);
  310. try {
  311. byte[] buf = new byte[1024];
  312. chunk_read = source.read(buf);
  313. do {
  314. destination.write(buf, 0, chunk_read);
  315. bytes_written += chunk_read;
  316. chunk_read = source.read(buf);
  317. } while (chunk_read != -1);
  318. } catch (IOException e) {
  319. Log.d("GameActivity", "Copying failed:" + e.getMessage());
  320. }
  321. // close streams
  322. try {
  323. source.close();
  324. destination.close();
  325. success = true;
  326. } catch (IOException e) {
  327. Log.d("GameActivity", "Copying failed: " + e.getMessage());
  328. }
  329. Log.d("GameActivity", "Successfully copied stream to " + destinationFileName + " (" + bytes_written + " bytes written).");
  330. return success;
  331. }
  332. @Keep
  333. public boolean hasBackgroundMusic() {
  334. AudioManager audioManager = (AudioManager) getSystemService(Context.AUDIO_SERVICE);
  335. return audioManager.isMusicActive();
  336. }
  337. @Keep
  338. public void showRecordingAudioPermissionMissingDialog() {
  339. Log.d("GameActivity", "showRecordingAudioPermissionMissingDialog()");
  340. runOnUiThread(new Runnable() {
  341. @Override
  342. public void run() {
  343. AlertDialog dialog = new AlertDialog.Builder(mSingleton)
  344. .setTitle("Audio Recording Permission Missing")
  345. .setMessage("It appears that this game uses mic capabilities. The game may not work correctly without mic permission!")
  346. .setNeutralButton("Continue", new DialogInterface.OnClickListener() {
  347. public void onClick(DialogInterface di, int id) {
  348. synchronized (recordAudioRequestDummy) {
  349. recordAudioRequestDummy.notify();
  350. }
  351. }
  352. })
  353. .create();
  354. dialog.show();
  355. }
  356. });
  357. synchronized (recordAudioRequestDummy) {
  358. try {
  359. recordAudioRequestDummy.wait();
  360. } catch (InterruptedException e) {
  361. Log.d("GameActivity", "mic permission dialog", e);
  362. }
  363. }
  364. }
  365. public void showExternalStoragePermissionMissingDialog() {
  366. AlertDialog dialog = new AlertDialog.Builder(mSingleton)
  367. .setTitle("Storage Permission Missing")
  368. .setMessage("LÖVE for Android will not be able to run non-packaged games without storage permission.")
  369. .setNeutralButton("Continue", null)
  370. .create();
  371. dialog.show();
  372. }
  373. @Override
  374. public void onRequestPermissionsResult(int requestCode, String[] permissions, int[] grantResults) {
  375. if (grantResults.length > 0) {
  376. Log.d("GameActivity", "Received a request permission result");
  377. switch (requestCode) {
  378. case EXTERNAL_STORAGE_REQUEST_CODE: {
  379. if (grantResults[0] == PackageManager.PERMISSION_GRANTED) {
  380. Log.d("GameActivity", "Permission granted");
  381. } else {
  382. Log.d("GameActivity", "Did not get permission.");
  383. if (ActivityCompat.shouldShowRequestPermissionRationale(this, Manifest.permission.READ_EXTERNAL_STORAGE)) {
  384. showExternalStoragePermissionMissingDialog();
  385. }
  386. }
  387. Log.d("GameActivity", "Unlocking LÖVE thread");
  388. synchronized (externalStorageRequestDummy) {
  389. externalStorageRequestDummy[0] = grantResults[0];
  390. externalStorageRequestDummy.notify();
  391. }
  392. break;
  393. }
  394. case RECORD_AUDIO_REQUEST_CODE: {
  395. if (grantResults[0] == PackageManager.PERMISSION_GRANTED) {
  396. Log.d("GameActivity", "Mic permission granted");
  397. } else {
  398. Log.d("GameActivity", "Did not get mic permission.");
  399. }
  400. Log.d("GameActivity", "Unlocking LÖVE thread");
  401. synchronized (recordAudioRequestDummy) {
  402. recordAudioRequestDummy[0] = grantResults[0];
  403. recordAudioRequestDummy.notify();
  404. }
  405. break;
  406. }
  407. default:
  408. super.onRequestPermissionsResult(requestCode, permissions, grantResults);
  409. }
  410. }
  411. }
  412. @Keep
  413. public boolean hasExternalStoragePermission() {
  414. if (ActivityCompat.checkSelfPermission(this,
  415. Manifest.permission.READ_EXTERNAL_STORAGE)
  416. == PackageManager.PERMISSION_GRANTED) {
  417. return true;
  418. }
  419. Log.d("GameActivity", "Requesting permission and locking LÖVE thread until we have an answer.");
  420. ActivityCompat.requestPermissions(this, new String[]{Manifest.permission.READ_EXTERNAL_STORAGE}, EXTERNAL_STORAGE_REQUEST_CODE);
  421. synchronized (externalStorageRequestDummy) {
  422. try {
  423. externalStorageRequestDummy.wait();
  424. } catch (InterruptedException e) {
  425. Log.d("GameActivity", "requesting external storage permission", e);
  426. return false;
  427. }
  428. }
  429. return ActivityCompat.checkSelfPermission(this, Manifest.permission.READ_EXTERNAL_STORAGE) == PackageManager.PERMISSION_GRANTED;
  430. }
  431. @Keep
  432. public boolean hasRecordAudioPermission() {
  433. return ActivityCompat.checkSelfPermission(this, Manifest.permission.RECORD_AUDIO) == PackageManager.PERMISSION_GRANTED;
  434. }
  435. @Keep
  436. public void requestRecordAudioPermission() {
  437. if (ActivityCompat.checkSelfPermission(this,
  438. Manifest.permission.RECORD_AUDIO)
  439. == PackageManager.PERMISSION_GRANTED) {
  440. return;
  441. }
  442. Log.d("GameActivity", "Requesting mic permission and locking LÖVE thread until we have an answer.");
  443. ActivityCompat.requestPermissions(this, new String[]{Manifest.permission.RECORD_AUDIO}, RECORD_AUDIO_REQUEST_CODE);
  444. synchronized (recordAudioRequestDummy) {
  445. try {
  446. recordAudioRequestDummy.wait();
  447. } catch (InterruptedException e) {
  448. Log.d("GameActivity", "requesting mic permission", e);
  449. }
  450. }
  451. }
  452. @Keep
  453. public boolean initializeSafeArea() {
  454. if (android.os.Build.VERSION.SDK_INT >= 28 && shortEdgesMode) {
  455. DisplayCutout cutout = getWindow().getDecorView().getRootWindowInsets().getDisplayCutout();
  456. if (cutout != null) {
  457. safeAreaTop = cutout.getSafeInsetTop();
  458. safeAreaLeft = cutout.getSafeInsetLeft();
  459. safeAreaBottom = cutout.getSafeInsetBottom();
  460. safeAreaRight = cutout.getSafeInsetRight();
  461. return true;
  462. }
  463. }
  464. return false;
  465. }
  466. @Keep
  467. public String[] buildFileTree() {
  468. // Map key is path, value is directory flag
  469. HashMap<String, Boolean> map = buildFileTree(getAssets(), "", new HashMap<String, Boolean>());
  470. ArrayList<String> result = new ArrayList<String>();
  471. for (Map.Entry<String, Boolean> data: map.entrySet()) {
  472. result.add((data.getValue() ? "d" : "f") + data.getKey());
  473. }
  474. String[] r = new String[result.size()];
  475. result.toArray(r);
  476. return r;
  477. }
  478. private HashMap<String, Boolean> buildFileTree(AssetManager assetManager, String dir, HashMap<String, Boolean> map) {
  479. String strippedDir = dir.endsWith("/") ? dir.substring(0, dir.length() - 1) : dir;
  480. // Try open dir
  481. try {
  482. InputStream test = assetManager.open(strippedDir);
  483. // It's a file
  484. test.close();
  485. map.put(strippedDir, false);
  486. } catch (FileNotFoundException e) {
  487. // It's a directory
  488. String[] list = null;
  489. // List files
  490. try {
  491. list = assetManager.list(strippedDir);
  492. } catch (IOException e2) {
  493. Log.e("GameActivity", strippedDir, e2);
  494. }
  495. // Mark as file
  496. map.put(dir, true);
  497. // This Object comparison is intentional.
  498. if (strippedDir != dir) {
  499. map.put(strippedDir, true);
  500. }
  501. if (list != null) {
  502. for (String path: list) {
  503. buildFileTree(assetManager, dir + path + "/", map);
  504. }
  505. }
  506. } catch (IOException e) {
  507. Log.e("GameActivity", dir, e);
  508. }
  509. return map;
  510. }
  511. public int getAudioSMP() {
  512. int smp = 256;
  513. if (android.os.Build.VERSION.SDK_INT >= 17) {
  514. AudioManager a = (AudioManager) getSystemService(Context.AUDIO_SERVICE);
  515. int b = Integer.parseInt(a.getProperty(AudioManager.PROPERTY_OUTPUT_FRAMES_PER_BUFFER));
  516. return b > 0 ? b : smp;
  517. }
  518. return smp;
  519. }
  520. public int getAudioFreq() {
  521. int freq = 44100;
  522. if (android.os.Build.VERSION.SDK_INT >= 17) {
  523. AudioManager a = (AudioManager) getSystemService(Context.AUDIO_SERVICE);
  524. int b = Integer.parseInt(a.getProperty(AudioManager.PROPERTY_OUTPUT_SAMPLE_RATE));
  525. return b > 0 ? b : freq;
  526. }
  527. return freq;
  528. }
  529. public boolean isNativeLibsExtracted() {
  530. ApplicationInfo appInfo = getApplicationInfo();
  531. if (android.os.Build.VERSION.SDK_INT >= 23) {
  532. return (appInfo.flags & ApplicationInfo.FLAG_EXTRACT_NATIVE_LIBS) != 0;
  533. }
  534. return true;
  535. }
  536. @Keep
  537. public String getCRequirePath() {
  538. ApplicationInfo applicationInfo = getApplicationInfo();
  539. if (isNativeLibsExtracted()) {
  540. return applicationInfo.nativeLibraryDir + "/?.so";
  541. } else {
  542. // The native libs are inside the APK and can be loaded directly.
  543. // FIXME: What about split APKs?
  544. String abi;
  545. if (android.os.Build.VERSION.SDK_INT >= 21) {
  546. abi = android.os.Build.SUPPORTED_ABIS[0];
  547. } else {
  548. // This codepath should NEVER be taken as if isNativeLibsExtracted()
  549. // returns false, it's 100% safe to assume we're on API level 23 or later.
  550. abi = android.os.Build.CPU_ABI;
  551. }
  552. return applicationInfo.sourceDir + "!/lib/" + abi + "/?.so";
  553. }
  554. }
  555. }