/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
 * This Source Code Form is subject to the terms of the Mozilla Public
 * License, v. 2.0. If a copy of the MPL was not distributed with this
 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */

package org.mozilla.gecko.mozglue;

import android.content.Context;
import android.os.Build;
import android.os.Environment;
import android.util.Log;
import dalvik.system.BaseDexClassLoader;
import java.io.File;
import java.io.FileOutputStream;
import java.io.InputStream;
import java.util.Collection;
import java.util.Locale;
import java.util.Map;
import java.util.zip.ZipEntry;
import java.util.zip.ZipFile;
import org.mozilla.gecko.GeckoAppShell;
import org.mozilla.gecko.annotation.JNITarget;
import org.mozilla.gecko.annotation.RobocopTarget;

public final class GeckoLoader {
  private static final String LOGTAG = "GeckoLoader";

  private static File sGREDir;

  /* Synchronized on GeckoLoader.class. */
  private static boolean sSQLiteLibsLoaded;
  private static boolean sNSSLibsLoaded;
  private static boolean sMozGlueLoaded;

  private GeckoLoader() {
    // prevent instantiation
  }

  public static File getGREDir(final Context context) {
    if (sGREDir == null) {
      sGREDir = new File(context.getApplicationInfo().dataDir);
    }
    return sGREDir;
  }

  private static void setupDownloadEnvironment(final Context context) {
    try {
      File downloadDir =
          Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS);
      File updatesDir = context.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS);
      if (downloadDir == null) {
        downloadDir = new File(Environment.getExternalStorageDirectory().getPath(), "download");
      }
      if (updatesDir == null) {
        updatesDir = downloadDir;
      }
      putenv("DOWNLOADS_DIRECTORY=" + downloadDir.getPath());
      putenv("UPDATES_DIRECTORY=" + updatesDir.getPath());
    } catch (final Exception e) {
      Log.w(LOGTAG, "No download directory found.", e);
    }
  }

  private static void delTree(final File file) {
    if (file.isDirectory()) {
      final File[] children = file.listFiles();
      for (final File child : children) {
        delTree(child);
      }
    }
    file.delete();
  }

  private static File getTmpDir(final Context context) {
    // It's important that this folder is in the cache directory so users can actually
    // clear it when it gets too big.
    return new File(context.getCacheDir(), "gecko_temp");
  }

  private static String escapeDoubleQuotes(final String str) {
    return str.replaceAll("\"", "\\\"");
  }

  private static void setupInitialPrefs(final Map<String, Object> prefs) {
    if (prefs != null) {
      final StringBuilder prefsEnv = new StringBuilder("MOZ_DEFAULT_PREFS=");
      for (final String key : prefs.keySet()) {
        final Object value = prefs.get(key);
        if (value == null) {
          continue;
        }
        prefsEnv.append(String.format("pref(\"%s\",", escapeDoubleQuotes(key)));
        if (value instanceof String) {
          prefsEnv.append(String.format("\"%s\"", escapeDoubleQuotes(value.toString())));
        } else if (value instanceof Boolean) {
          prefsEnv.append((Boolean) value ? "true" : "false");
        } else {
          prefsEnv.append(value.toString());
        }

        prefsEnv.append(");\n");
      }

      putenv(prefsEnv.toString());
    }
  }

  @SuppressWarnings("deprecation") // for Build.CPU_ABI
  public static synchronized void setupGeckoEnvironment(
      final Context context,
      final boolean isChildProcess,
      final String profilePath,
      final Collection<String> env,
      final Map<String, Object> prefs,
      final boolean xpcshell) {
    for (final String e : env) {
      putenv(e);
    }

    putenv("MOZ_ANDROID_PACKAGE_NAME=" + context.getPackageName());

    if (!isChildProcess) {
      setupDownloadEnvironment(context);

      // profile home path
      putenv("HOME=" + profilePath);

      // setup the downloads path
      File f = Environment.getDownloadCacheDirectory();
      putenv("EXTERNAL_STORAGE=" + f.getPath());

      // setup the app-specific cache path
      f = context.getCacheDir();
      putenv("CACHE_DIRECTORY=" + f.getPath());

      f = context.getExternalFilesDir(null);
      if (f != null) {
        putenv("PUBLIC_STORAGE=" + f.getPath());
      }

      if (Build.VERSION.SDK_INT >= 17) {
        final android.os.UserManager um =
            (android.os.UserManager) context.getSystemService(Context.USER_SERVICE);
        if (um != null) {
          putenv(
              "MOZ_ANDROID_USER_SERIAL_NUMBER="
                  + um.getSerialNumberForUser(android.os.Process.myUserHandle()));
        } else {
          Log.d(
              LOGTAG,
              "Unable to obtain user manager service on a device with SDK version "
                  + Build.VERSION.SDK_INT);
        }
      }

      setupInitialPrefs(prefs);
    }

    // Xpcshell tests set up their own temp directory
    if (!xpcshell) {
      // setup the tmp path
      final File f = getTmpDir(context);
      if (!f.exists()) {
        f.mkdirs();
      }
      putenv("TMPDIR=" + f.getPath());
    }

    putenv("LANG=" + Locale.getDefault().toString());

    final Class<?> crashHandler = GeckoAppShell.getCrashHandlerService();
    if (crashHandler != null) {
      putenv(
          "MOZ_ANDROID_CRASH_HANDLER=" + context.getPackageName() + "/" + crashHandler.getName());
    }

    putenv("MOZ_ANDROID_DEVICE_SDK_VERSION=" + Build.VERSION.SDK_INT);
    putenv("MOZ_ANDROID_CPU_ABI=" + Build.CPU_ABI);

    // env from extras could have reset out linker flags; set them again.
    loadLibsSetupLocked(context);
  }

  // Adapted from
  // https://source.chromium.org/chromium/chromium/src/+/main:base/android/java/src/org/chromium/base/BundleUtils.java;l=196;drc=c0fedddd4a1444653235912cfae3d44b544ded01
  private static String getLibraryPath(final String libraryName) {
    // Due to b/171269960 isolated split class loaders have an empty library path, so check
    // the base module class loader first which loaded GeckoAppShell. If the library is not
    // found there, attempt to construct the correct library path from the split.
    String path =
        ((BaseDexClassLoader) GeckoAppShell.class.getClassLoader()).findLibrary(libraryName);
    if (path != null) {
      return path;
    }

    // SplitCompat is installed on the application context, so check there for library paths
    // which were added to that ClassLoader.
    final ClassLoader classLoader = GeckoAppShell.getApplicationContext().getClassLoader();
    if (classLoader instanceof BaseDexClassLoader) {
      path = ((BaseDexClassLoader) classLoader).findLibrary(libraryName);
      if (path != null) {
        return path;
      }
    }

    throw new RuntimeException("Could not find mozglue path.");
  }

  private static String getLibraryBase() {
    final String mozglue = getLibraryPath("mozglue");
    final int lastSlash = mozglue.lastIndexOf('/');
    if (lastSlash < 0) {
      throw new IllegalStateException("Invalid library path for libmozglue.so: " + mozglue);
    }
    final String base = mozglue.substring(0, lastSlash);
    Log.i(LOGTAG, "Library base=" + base);
    return base;
  }

  private static void loadLibsSetupLocked(final Context context) {
    putenv("GRE_HOME=" + getGREDir(context).getPath());
    putenv("MOZ_ANDROID_LIBDIR=" + getLibraryBase());
  }

  @RobocopTarget
  public static synchronized void loadSQLiteLibs(final Context context) {
    if (sSQLiteLibsLoaded) {
      return;
    }

    loadMozGlue(context);
    loadLibsSetupLocked(context);
    loadSQLiteLibsNative();
    sSQLiteLibsLoaded = true;
  }

  public static synchronized void loadNSSLibs(final Context context) {
    if (sNSSLibsLoaded) {
      return;
    }

    loadMozGlue(context);
    loadLibsSetupLocked(context);
    loadNSSLibsNative();
    sNSSLibsLoaded = true;
  }

  @SuppressWarnings("deprecation")
  private static String getCPUABI() {
    return android.os.Build.CPU_ABI;
  }

  /**
   * Copy a library out of our APK.
   *
   * @param context a Context.
   * @param lib the name of the library; e.g., "mozglue".
   * @param outDir the output directory for the .so. No trailing slash.
   * @return true on success, false on failure.
   */
  private static boolean extractLibrary(
      final Context context, final String lib, final String outDir) {
    final String apkPath = context.getApplicationInfo().sourceDir;

    // Sanity check.
    if (!apkPath.endsWith(".apk")) {
      Log.w(LOGTAG, "sourceDir is not an APK.");
      return false;
    }

    // Try to extract the named library from the APK.
    final File outDirFile = new File(outDir);
    if (!outDirFile.isDirectory()) {
      if (!outDirFile.mkdirs()) {
        Log.e(LOGTAG, "Couldn't create " + outDir);
        return false;
      }
    }

    if (Build.VERSION.SDK_INT >= 21) {
      final String[] abis = Build.SUPPORTED_ABIS;
      for (final String abi : abis) {
        if (tryLoadWithABI(lib, outDir, apkPath, abi)) {
          return true;
        }
      }
      return false;
    } else {
      final String abi = getCPUABI();
      return tryLoadWithABI(lib, outDir, apkPath, abi);
    }
  }

  private static boolean tryLoadWithABI(
      final String lib, final String outDir, final String apkPath, final String abi) {
    try {
      final ZipFile zipFile = new ZipFile(new File(apkPath));
      try {
        final String libPath = "lib/" + abi + "/lib" + lib + ".so";
        final ZipEntry entry = zipFile.getEntry(libPath);
        if (entry == null) {
          Log.w(LOGTAG, libPath + " not found in APK " + apkPath);
          return false;
        }

        final InputStream in = zipFile.getInputStream(entry);
        try {
          final String outPath = outDir + "/lib" + lib + ".so";
          final FileOutputStream out = new FileOutputStream(outPath);
          final byte[] bytes = new byte[1024];
          int read;

          Log.d(LOGTAG, "Copying " + libPath + " to " + outPath);
          boolean failed = false;
          try {
            while ((read = in.read(bytes, 0, 1024)) != -1) {
              out.write(bytes, 0, read);
            }
          } catch (final Exception e) {
            Log.w(LOGTAG, "Failing library copy.", e);
            failed = true;
          } finally {
            out.close();
          }

          if (failed) {
            // Delete the partial copy so we don't fail to load it.
            // Don't bother to check the return value -- there's nothing
            // we can do about a failure.
            new File(outPath).delete();
          } else {
            // Mark the file as executable. This doesn't seem to be
            // necessary for the loader, but it's the normal state of
            // affairs.
            Log.d(LOGTAG, "Marking " + outPath + " as executable.");
            new File(outPath).setExecutable(true);
          }

          return !failed;
        } finally {
          in.close();
        }
      } finally {
        zipFile.close();
      }
    } catch (final Exception e) {
      Log.e(LOGTAG, "Failed to extract lib from APK.", e);
      return false;
    }
  }

  private static boolean attemptLoad(final String path) {
    try {
      System.load(path);
      return true;
    } catch (final Throwable e) {
      Log.wtf(LOGTAG, "Couldn't load " + path + ": " + e);
    }

    return false;
  }

  /**
   * The first two attempts at loading a library: directly, and then using the app library path.
   *
   * <p>Returns null or the cause exception.
   */
  public static Throwable doLoadLibrary(final Context context, final String lib) {
    try {
      // Attempt 1: the way that should work.
      System.loadLibrary(lib);
      return null;
    } catch (final Throwable e) {
      final String libPath = getLibraryPath(lib);
      // Does it even exist?
      if (new File(libPath).exists()) {
        if (attemptLoad(libPath)) {
          // Success!
          return null;
        }
        throw new RuntimeException(
            "Library exists but couldn't load." + "Path: " + libPath + " lib: " + lib, e);
      }
      throw new RuntimeException(
          "Library doesn't exist when it should." + "Path: " + libPath + " lib: " + lib, e);
    }
  }

  public static synchronized void loadMozGlue(final Context context) {
    if (sMozGlueLoaded) {
      return;
    }

    doLoadLibrary(context, "mozglue");
    sMozGlueLoaded = true;
  }

  public static synchronized void loadGeckoLibs(final Context context) {
    loadLibsSetupLocked(context);
    loadGeckoLibsNative();
  }

  @SuppressWarnings("serial")
  public static class AbortException extends Exception {
    public AbortException(final String msg) {
      super(msg);
    }
  }

  @JNITarget
  public static void abort(final String msg) {
    final Thread thread = Thread.currentThread();
    final Thread.UncaughtExceptionHandler uncaughtHandler = thread.getUncaughtExceptionHandler();
    if (uncaughtHandler != null) {
      uncaughtHandler.uncaughtException(thread, new AbortException(msg));
    }
  }

  // These methods are implemented in mozglue/android/nsGeckoUtils.cpp
  private static native void putenv(String map);

  // These methods are implemented in mozglue/android/APKOpen.cpp
  public static native void nativeRun(
      String[] args,
      int prefsFd,
      int prefMapFd,
      int ipcFd,
      int crashFd,
      boolean xpcshell,
      String outFilePath);

  private static native void loadGeckoLibsNative();

  private static native void loadSQLiteLibsNative();

  private static native void loadNSSLibsNative();

  public static native void suppressCrashDialog();
}
