diff options
author | Sven Gothel <[email protected]> | 2012-03-14 23:07:21 +0100 |
---|---|---|
committer | Sven Gothel <[email protected]> | 2012-03-14 23:07:21 +0100 |
commit | 0cfc7847c58b51c9a26b50d905b592d1fc4c8578 (patch) | |
tree | 2764affabade9825b3e1b13035632b4934e3a690 /src/java/jogamp | |
parent | eedb4b530fb83fc59a26962bcf7847a1404092a0 (diff) |
Android: New ActivityLauncher (jogamp.android-launcher.apk)
ActivityLauncher provides delegating Activities, allowing the user to:
- daisy chain custom APK classes and native libraries to the classpath
- name one custom activity which gets delegated to, the downstream activity
Overview:
[User:a1] -- (usr-data) --> [Launcher] -> [User:a2] + using [other packages..]
[User APK] - The user provided APK
[JogAmp APK] - JogAmp APKs
[User:a1] - The initial user activity, which starts the [Launcher].
Providing data to [Launcher]: [User:a2], [User APK]
Resides in [User APK]
[User:a2] - The actual downstream 'real' activity, spoiled w/ full fledged ClassLoader
having access to all packages as requested, ie. [User APK], ..
Resides in [User APK]
[Launcher] - The launcher activity.
Gets called by [User:a1].
Creates a new ClassLoader, daisy chainging all requested APKs.
Instantiates [User:a2] w/ new ClassLoader.
Delegates all calls to [User:a2].
Resides in [JogAmp APK].
Diffstat (limited to 'src/java/jogamp')
-rw-r--r-- | src/java/jogamp/android/launcher/ActivityLauncher.java | 231 | ||||
-rw-r--r-- | src/java/jogamp/android/launcher/ClassLoaderUtil.java | 193 | ||||
-rw-r--r-- | src/java/jogamp/android/launcher/LauncherMain.java | 63 | ||||
-rw-r--r-- | src/java/jogamp/android/launcher/LauncherTempFileCache.java | 477 | ||||
-rw-r--r-- | src/java/jogamp/android/launcher/LauncherUtil.java | 320 |
5 files changed, 1284 insertions, 0 deletions
diff --git a/src/java/jogamp/android/launcher/ActivityLauncher.java b/src/java/jogamp/android/launcher/ActivityLauncher.java new file mode 100644 index 0000000..04c898e --- /dev/null +++ b/src/java/jogamp/android/launcher/ActivityLauncher.java @@ -0,0 +1,231 @@ +/** + * Copyright 2011 JogAmp Community. All rights reserved. + * + * Redistribution and use in source and binary forms, with or without modification, are + * permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this list of + * conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, this list + * of conditions and the following disclaimer in the documentation and/or other materials + * provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY JogAmp Community ``AS IS'' AND ANY EXPRESS OR IMPLIED + * WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND + * FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL JogAmp Community OR + * CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR + * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON + * ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING + * NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF + * ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + * + * The views and conclusions contained in the software and documentation are those of the + * authors and should not be interpreted as representing official policies, either expressed + * or implied, of JogAmp Community. + */ +package jogamp.android.launcher; + +import java.lang.reflect.Constructor; +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; + +import android.app.Activity; +import android.content.Context; +import android.net.Uri; +import android.os.Bundle; +import android.widget.TextView; +import android.util.Log; + +public class ActivityLauncher extends Activity { + static final String TAG = "NEWTLauncherActivity"; + TextView tv = null; + Method mOnCreate, mOnDestroy, mOnPause, mOnRestart, mOnResume, + mOnStart, mOnStop, mSetIsInvokedByExternalActivity; + Class<?> activityClazz = null; + Object activityObject = null; + + @Override + public void onCreate(Bundle savedInstanceState) { + Log.d(TAG, "onCreate - S"); + super.onCreate(savedInstanceState); + + final Uri uri = getIntent().getData(); + final LauncherUtil.DataSet data = LauncherUtil.DataSet.create(uri); + data.setSystemProperties(); + + ClassLoader cl = ClassLoaderUtil.createJogampClassLoaderSingleton(this, data.getPackages()); + if(null != cl) { + try { + activityClazz = Class.forName(data.getActivityName(), true, cl); + Log.d(TAG, "Activity Clazz "+activityClazz); + activityObject = createInstance(activityClazz, null); + Log.d(TAG, "Activity Object "+activityObject); + mOnCreate = activityClazz.getMethod("onCreate", Bundle.class); + mOnDestroy = activityClazz.getMethod("onDestroy"); + mOnPause = activityClazz.getMethod("onPause"); + mOnRestart = activityClazz.getMethod("onRestart"); + mOnResume = activityClazz.getMethod("onResume"); + mOnStart = activityClazz.getMethod("onStart"); + mOnStop = activityClazz.getMethod("onStop"); + mSetIsInvokedByExternalActivity = activityClazz.getMethod("setIsInvokedByExternalActivity", Activity.class); + } catch (Exception e) { + Log.d(TAG, "error: "+e, e); + throw new RuntimeException(e); + } + } + + if( null == mOnCreate || null == mOnDestroy || null == mOnPause || + null == mOnRestart || null == mOnResume || + null == mSetIsInvokedByExternalActivity ) { + RuntimeException e = new RuntimeException("XXX - incomplete method set"); + Log.d(TAG, "error: "+e, e); + throw e; + } + + callMethod(activityObject, mSetIsInvokedByExternalActivity, this); + + callMethod(activityObject, mOnCreate, savedInstanceState); + Log.d(TAG, "onCreate - X"); + } + + @Override + public void onStart() { + Log.d(TAG, "onStart - S"); + callMethod(activityObject, mOnStart); + super.onStart(); + Log.d(TAG, "onStart - X"); + } + + @Override + public void onRestart() { + Log.d(TAG, "onRestart - S"); + callMethod(activityObject, mOnRestart); + super.onRestart(); + Log.d(TAG, "onRestart - X"); + } + + @Override + public void onResume() { + Log.d(TAG, "onResume - S"); + callMethod(activityObject, mOnResume); + super.onResume(); + Log.d(TAG, "onResume - X"); + } + + @Override + public void onPause() { + Log.d(TAG, "onPause - S"); + callMethod(activityObject, mOnPause); + super.onPause(); + Log.d(TAG, "onPause - X"); + } + + @Override + public void onStop() { + Log.d(TAG, "onStop - S"); + callMethod(activityObject, mOnStop); + super.onStop(); + Log.d(TAG, "onStop - X"); + } + + @Override + public void onDestroy() { + Log.d(TAG, "onDestroy - S"); + callMethod(activityObject, mOnDestroy); + super.onDestroy(); + finish(); + Log.d(TAG, "onDestroy - X"); + } + + @Override + public void finish() { + Log.d(TAG, "finish - S"); + super.finish(); + Log.d(TAG, "finish - X"); + } + + /** + * @throws JogampRuntimeException if the instance can not be created. + */ + public static final Object createInstance(Class<?> clazz, Class<?>[] cstrArgTypes, Object ... cstrArgs) + throws RuntimeException + { + return createInstance(getConstructor(clazz, cstrArgTypes), cstrArgs); + } + + public static final Object createInstance(Constructor<?> cstr, Object ... cstrArgs) + throws RuntimeException + { + try { + return cstr.newInstance(cstrArgs); + } catch (Exception e) { + Throwable t = e; + if (t instanceof InvocationTargetException) { + t = ((InvocationTargetException) t).getTargetException(); + } + if (t instanceof Error) { + throw (Error) t; + } + if (t instanceof RuntimeException) { + throw (RuntimeException) t; + } + throw new RuntimeException("can not create instance of "+cstr.getName(), t); + } + } + + /** + * @throws JogampRuntimeException if the constructor can not be delivered. + */ + protected static final Constructor<?> getConstructor(Class<?> clazz, Class<?> ... cstrArgTypes) + throws RuntimeException { + try { + if(null == cstrArgTypes) { + cstrArgTypes = zeroTypes; + } + return clazz.getDeclaredConstructor(cstrArgTypes); + } catch (NoSuchMethodException ex) { + throw new RuntimeException("Constructor: '" + clazz + "(" + asString(cstrArgTypes) + ")' not found", ex); + } + } + + protected static final Class<?>[] zeroTypes = new Class[0]; + + protected static final String asString(Class<?>[] argTypes) { + StringBuffer args = new StringBuffer(); + boolean coma = false; + if(null != argTypes) { + for (int i = 0; i < argTypes.length; i++) { + if(coma) { + args.append(", "); + } + args.append(argTypes[i].getName()); + coma = true; + } + } + return args.toString(); + } + + protected static final Object callMethod(Object instance, Method method, Object ... args) + throws RuntimeException + { + try { + return method.invoke(instance, args); + } catch (Exception e) { + Throwable t = e; + if (t instanceof InvocationTargetException) { + t = ((InvocationTargetException) t).getTargetException(); + } + if (t instanceof Error) { + throw (Error) t; + } + if (t instanceof RuntimeException) { + throw (RuntimeException) t; + } + throw new RuntimeException("calling "+method+" failed", t); + } + } + + +} diff --git a/src/java/jogamp/android/launcher/ClassLoaderUtil.java b/src/java/jogamp/android/launcher/ClassLoaderUtil.java new file mode 100644 index 0000000..319a0fd --- /dev/null +++ b/src/java/jogamp/android/launcher/ClassLoaderUtil.java @@ -0,0 +1,193 @@ +/** + * Copyright 2011 JogAmp Community. All rights reserved. + * + * Redistribution and use in source and binary forms, with or without modification, are + * permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this list of + * conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, this list + * of conditions and the following disclaimer in the documentation and/or other materials + * provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY JogAmp Community ``AS IS'' AND ANY EXPRESS OR IMPLIED + * WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND + * FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL JogAmp Community OR + * CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR + * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON + * ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING + * NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF + * ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + * + * The views and conclusions contained in the software and documentation are those of the + * authors and should not be interpreted as representing official policies, either expressed + * or implied, of JogAmp Community. + */ + +package jogamp.android.launcher; + +import java.io.File; +import java.lang.ref.WeakReference; +import java.lang.reflect.Field; +import java.util.HashMap; +import java.util.Iterator; +import java.util.List; + +import android.app.Activity; +import android.content.Context; +import android.content.pm.ApplicationInfo; +import android.content.pm.PackageManager; +import android.util.Log; +import dalvik.system.DexClassLoader; + +public class ClassLoaderUtil { + private static final String TAG = "JogampClassLoader"; + + public static final String packageGlueGen = "com.jogamp.common"; + public static final String packageJogl = "javax.media.opengl"; // FIXME: a 'performance' hack + + public static final String dexPathName= "jogampDex"; + + private static LauncherTempFileCache tmpFileCache = null; + private static ClassLoader jogAmpClassLoader = null; + + /** + * + * @param ctx + * @param userPackageNames list of user package names, the last entry shall reflect the Activity + * @return + */ + public static synchronized ClassLoader createJogampClassLoaderSingleton(Context ctx, List<String> userPackageNames) { + if(null==jogAmpClassLoader) { + if(null!=tmpFileCache) { + throw new InternalError("XXX0"); + } + if(!LauncherTempFileCache.initSingleton(ctx)) { + throw new InternalError("TempFileCache initialization error"); + } + tmpFileCache = new LauncherTempFileCache(); + if(!tmpFileCache.isValid()) { + throw new InternalError("TempFileCache instantiation error"); + } + final ApplicationInfo ai = ctx.getApplicationInfo(); + Log.d(TAG, "S: userPackageName: "+userPackageNames+", dataDir: "+ai.dataDir+", nativeLibraryDir: "+ai.nativeLibraryDir); + + final String appDir = new File(ai.dataDir).getParent(); + final String libSub = ai.nativeLibraryDir.substring(ai.nativeLibraryDir.lastIndexOf('/')+1); + Log.d(TAG, "S: appDir: "+appDir+", libSub: "+libSub); + + final String libPathName = appDir + "/" + packageGlueGen + "/" + libSub + "/:" + + appDir + "/" + packageJogl + "/" + libSub + "/" ; + Log.d(TAG, "S: libPath: "+libPathName); + + String apkGlueGen = null; + String apkJogl = null; + + try { + apkGlueGen = ctx.getPackageManager().getApplicationInfo(packageGlueGen,0).sourceDir; + apkJogl = ctx.getPackageManager().getApplicationInfo(packageJogl,0).sourceDir; + } catch (PackageManager.NameNotFoundException e) { + Log.d(TAG, "error: "+e, e); + } + if(null == apkGlueGen || null == apkJogl) { + Log.d(TAG, "not found: gluegen <"+apkGlueGen+">, jogl <"+apkJogl+">"); + return null; + } + + final String cp = apkGlueGen + ":" + apkJogl ; + Log.d(TAG, "jogamp cp: " + cp); + + final File dexPath = new File(tmpFileCache.getTempDir(), dexPathName); + Log.d(TAG, "jogamp dexPath: " + dexPath.getAbsolutePath()); + dexPath.mkdir(); + jogAmpClassLoader = new DexClassLoader(cp, dexPath.getAbsolutePath(), libPathName, ctx.getClassLoader()); + } else { + if(null==tmpFileCache) { + throw new InternalError("XXX1"); + } + } + + StringBuilder userAPKs = new StringBuilder(); + int numUserAPKs = 0; + String lastUserPackageName = null; // the very last one reflects the Activity + for(Iterator<String> i=userPackageNames.iterator(); i.hasNext(); ) { + lastUserPackageName = i.next(); + String userAPK = null; + try { + userAPK = ctx.getPackageManager().getApplicationInfo(lastUserPackageName,0).sourceDir; + } catch (PackageManager.NameNotFoundException e) { + Log.d(TAG, "error: "+e, e); + } + if(null != userAPK) { + numUserAPKs++; + if(numUserAPKs>0) { + userAPKs.append(":"); + } + userAPKs.append(userAPK); + Log.d(TAG, "APK found: <"+lastUserPackageName+"> -> <"+userAPK+">"); + } else { + Log.d(TAG, "APK not found: <"+lastUserPackageName+">"); + } + } + if( userPackageNames.size()!=numUserAPKs ) { + Log.d(TAG, "APKs incomplete, abort"); + return null; + } + + final String userAPKs_s = userAPKs.toString(); + Log.d(TAG, "user cp: " + userAPKs_s); + final File dexPath = new File(tmpFileCache.getTempDir(), lastUserPackageName); + Log.d(TAG, "user dexPath: " + dexPath.getAbsolutePath()); + dexPath.mkdir(); + ClassLoader cl = new DexClassLoader(userAPKs_s, dexPath.getAbsolutePath(), null, jogAmpClassLoader); + Log.d(TAG, "cl: " + cl); + + return cl; + } + + public boolean setAPKClassLoader(String activityPackageName, ClassLoader classLoader) + { + try { + Field mMainThread = getField(Activity.class, "mMainThread"); + Object mainThread = mMainThread.get(this); + Class<?> threadClass = mainThread.getClass(); + Field mPackages = getField(threadClass, "mPackages"); + + @SuppressWarnings("unchecked") + HashMap<String,?> map = (HashMap<String,?>) mPackages.get(mainThread); + WeakReference<?> ref = (WeakReference<?>) map.get(activityPackageName); + Object apk = ref.get(); + Class<?> apkClass = apk.getClass(); + Field mClassLoader = getField(apkClass, "mClassLoader"); + + mClassLoader.set(apk, classLoader); + + Log.d(TAG, "setAPKClassLoader: OK"); + + return true; + } catch (IllegalArgumentException e) { + e.printStackTrace(); + } catch (IllegalAccessException e) { + e.printStackTrace(); + } + Log.d(TAG, "setAPKClassLoader: FAILED"); + return false; + } + + private Field getField(Class<?> cls, String name) + { + for (Field field: cls.getDeclaredFields()) + { + if (!field.isAccessible()) { + field.setAccessible(true); + } + if (field.getName().equals(name)) { + return field; + } + } + return null; + } + +} diff --git a/src/java/jogamp/android/launcher/LauncherMain.java b/src/java/jogamp/android/launcher/LauncherMain.java new file mode 100644 index 0000000..bbdee1d --- /dev/null +++ b/src/java/jogamp/android/launcher/LauncherMain.java @@ -0,0 +1,63 @@ +/** + * Copyright 2011 JogAmp Community. All rights reserved. + * + * Redistribution and use in source and binary forms, with or without modification, are + * permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this list of + * conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, this list + * of conditions and the following disclaimer in the documentation and/or other materials + * provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY JogAmp Community ``AS IS'' AND ANY EXPRESS OR IMPLIED + * WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND + * FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL JogAmp Community OR + * CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR + * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON + * ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING + * NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF + * ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + * + * The views and conclusions contained in the software and documentation are those of the + * authors and should not be interpreted as representing official policies, either expressed + * or implied, of JogAmp Community. + */ +package jogamp.android.launcher; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; + +import android.app.ActivityGroup; +import android.content.Intent; +import android.os.Looper; +import android.util.Log; + +public class LauncherMain { + static final String TAG = "NEWTLauncherMain"; + + static String userActivityName = null; + static ArrayList<String> userPackageNames = new ArrayList<String>(); + static boolean setAPKClasspath = false; + + public static String getUserActivityName() { return userActivityName; } + public static List<String> getUserPackageNames() { return userPackageNames; } + + public static void main(String args[]) throws IOException, ClassNotFoundException { + userActivityName = "com.jogamp.opengl.test.android.NEWTGearsES2Activity"; + userPackageNames.add("com.jogamp.opengl.test"); + Looper.prepareMainLooper(); + ActivityGroup activityGroup = new ActivityGroup(true); + ClassLoader cl = ClassLoaderUtil.createJogampClassLoaderSingleton(activityGroup, getUserPackageNames()); + if(null != cl) { + Class<?> activityClazz = Class.forName(getUserActivityName(), true, cl); + Intent intent = new Intent(activityGroup, activityClazz); + Log.d(TAG, "Launching Activity: "+activityClazz+", "+intent); + android.view.Window activityWindow = activityGroup.getLocalActivityManager().startActivity ("ID001", intent); + // activityGroup.addContentView(view, params) + } + } +} diff --git a/src/java/jogamp/android/launcher/LauncherTempFileCache.java b/src/java/jogamp/android/launcher/LauncherTempFileCache.java new file mode 100644 index 0000000..06b8516 --- /dev/null +++ b/src/java/jogamp/android/launcher/LauncherTempFileCache.java @@ -0,0 +1,477 @@ +/** + * Copyright 2011 JogAmp Community. All rights reserved. + * + * Redistribution and use in source and binary forms, with or without modification, are + * permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this list of + * conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, this list + * of conditions and the following disclaimer in the documentation and/or other materials + * provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY JogAmp Community ``AS IS'' AND ANY EXPRESS OR IMPLIED + * WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND + * FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL JogAmp Community OR + * CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR + * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON + * ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING + * NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF + * ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + * + * The views and conclusions contained in the software and documentation are those of the + * authors and should not be interpreted as representing official policies, either expressed + * or implied, of JogAmp Community. + */ +package jogamp.android.launcher; + +import java.io.File; +import java.io.FileOutputStream; +import java.io.FilenameFilter; +import java.io.IOException; +import java.nio.channels.FileChannel; +import java.nio.channels.FileLock; + +import android.content.Context; + +public class LauncherTempFileCache { + private static final boolean DEBUG = true; + + // Lifecycle: For all JVMs, ClassLoader and times. + private static final String tmpDirPrefix = "jogamp.tmp.cache"; + + // Get the value of the tmproot system property + // Lifecycle: For all JVMs and ClassLoader + /* package */ static final String tmpRootPropName = "jnlp.jogamp.tmp.cache.root"; + + // Flag indicating that we got a fatal error in the static initializer. + private static boolean staticInitError = false; + + private static File tmpBaseDir; + + // String representing the name of the temp root directory relative to the + // tmpBaseDir. Its value is "jlnNNNNN", which is the unique filename created + // by File.createTempFile() without the ".tmp" extension. + // + // Lifecycle: For all JVMs and ClassLoader + // + private static String tmpRootPropValue; + + private static File tmpRootDir; + + // Flag indicating that we got a fatal error in the initializer. + private boolean initError = false; + + private File individualTmpDir; + + /** + * Documented way to kick off static initialization + * @return true is static initialization was successful + */ + public static synchronized boolean initSingleton(Context ctx) { + if(null == tmpRootDir && !staticInitError) { + // Create / initialize the temp root directory, starting the Reaper + // thread to reclaim old installations if necessary. If we get an + // exception, set an error code. + try { + initTmpRoot(ctx); + } catch (Exception ex) { + ex.printStackTrace(); + staticInitError = true; + } + } + return !staticInitError; + } + + /** + * This method is called by the static initializer to create / initialize + * the temp root directory that will hold the temp directories for this + * instance of the JVM. This is done as follows: + * + * 1. Synchronize on a global lock. Note that for this purpose we will + * use System.out in the absence of a true global lock facility. + * We are careful not to hold this lock too long. + * + * 2. Check for the existence of the "jnlp.applet.launcher.tmproot" + * system property. + * + * a. If set, then some other thread in a different ClassLoader has + * already created the tmprootdir, so we just need to + * use it. The remaining steps are skipped. + * + * b. If not set, then we are the first thread in this JVM to run, + * and we need to create the the tmprootdir. + * + * 3. Create the tmprootdir, along with the appropriate locks. + * Note that we perform the operations in the following order, + * prior to creating tmprootdir itself, to work around the fact that + * the file creation and file lock steps are not atomic, and we need + * to ensure that a newly-created tmprootdir isn't reaped by a + * concurrently running JVM. + * + * create jlnNNNN.tmp using File.createTempFile() + * lock jlnNNNN.tmp + * create jlnNNNN.lck while holding the lock on the .tmp file + * lock jlnNNNN.lck + * + * Since the Reaper thread will enumerate the list of *.lck files + * before starting, we can guarantee that if there exists a *.lck file + * for an active process, then the corresponding *.tmp file is locked + * by that active process. This guarantee lets us avoid reaping an + * active process' files. + * + * 4. Set the "jnlp.applet.launcher.tmproot" system property. + * + * 5. Add a shutdown hook to cleanup jlnNNNN.lck and jlnNNNN.tmp. We + * don't actually expect that this shutdown hook will ever be called, + * but the act of doing this, ensures that the locks never get + * garbage-collected, which is necessary for correct behavior when + * the first ClassLoader is later unloaded, while subsequent Applets + * are still running. + * + * 6. Start the Reaper thread to cleanup old installations. + */ + private static void initTmpRoot(Context ctx) throws IOException { + if (DEBUG) { + System.err.println("TempFileCache: Static Initialization ----------------------------------------------"); + System.err.println("TempFileCache: Thread: "+Thread.currentThread().getName()+", CL 0x"+Integer.toHexString(LauncherTempFileCache.class.getClassLoader().hashCode())); + } + + synchronized (System.out) { + // Get the name of the tmpbase directory. + { + final File tmpRoot = ctx.getDir("temp", Context.MODE_WORLD_READABLE); + tmpBaseDir = new File(tmpRoot, tmpDirPrefix); + } + tmpRootPropValue = System.getProperty(tmpRootPropName); + + if (tmpRootPropValue == null) { + // Create the tmpbase directory if it doesn't already exist + tmpBaseDir.mkdir(); + if (!tmpBaseDir.isDirectory()) { + throw new IOException("Cannot create directory " + tmpBaseDir); + } + + // Create ${tmpbase}/jlnNNNN.tmp then lock the file + File tmpFile = File.createTempFile("jln", ".tmp", tmpBaseDir); + if (DEBUG) { + System.err.println("TempFileCache: tmpFile = " + tmpFile.getAbsolutePath()); + } + final FileOutputStream tmpOut = new FileOutputStream(tmpFile); + final FileChannel tmpChannel = tmpOut.getChannel(); + final FileLock tmpLock = tmpChannel.lock(); + + // Strip off the ".tmp" to get the name of the tmprootdir + String tmpFileName = tmpFile.getAbsolutePath(); + String tmpRootName = tmpFileName.substring(0, tmpFileName.lastIndexOf(".tmp")); + + // create ${tmpbase}/jlnNNNN.lck then lock the file + String lckFileName = tmpRootName + ".lck"; + File lckFile = new File(lckFileName); + if (DEBUG) { + System.err.println("TempFileCache: lckFile = " + lckFile.getAbsolutePath()); + } + lckFile.createNewFile(); + final FileOutputStream lckOut = new FileOutputStream(lckFile); + final FileChannel lckChannel = lckOut.getChannel(); + final FileLock lckLock = lckChannel.lock(); + + // Create tmprootdir + tmpRootDir = new File(tmpRootName); + if (DEBUG) { + System.err.println("TempFileCache: tmpRootDir = " + tmpRootDir.getAbsolutePath()); + } + if (!tmpRootDir.mkdir()) { + throw new IOException("Cannot create " + tmpRootDir); + } + + // Add shutdown hook to cleanup the OutputStream, FileChannel, + // and FileLock for the jlnNNNN.lck and jlnNNNN.lck files. + // We do this so that the locks never get garbage-collected. + Runtime.getRuntime().addShutdownHook(new Thread() { + /* @Override */ + public void run() { + // NOTE: we don't really expect that this code will ever + // be called. If it does, we will close the output + // stream, which will in turn close the channel. + // We will then release the lock. + try { + tmpOut.close(); + tmpLock.release(); + lckOut.close(); + lckLock.release(); + } catch (IOException ex) { + // Do nothing + } + } + }); + + // Set the system property... + tmpRootPropValue = tmpRootName.substring(tmpRootName.lastIndexOf(File.separator) + 1); + System.setProperty(tmpRootPropName, tmpRootPropValue); + if (DEBUG) { + System.err.println("TempFileCache: Setting " + tmpRootPropName + "=" + tmpRootPropValue); + } + + // Start a new Reaper thread to do stuff... + Thread reaperThread = new Thread() { + /* @Override */ + public void run() { + deleteOldTempDirs(); + } + }; + reaperThread.setName("TempFileCache-Reaper"); + reaperThread.start(); + } else { + // Make sure that the property is not set to an illegal value + if (tmpRootPropValue.indexOf('/') >= 0 || + tmpRootPropValue.indexOf(File.separatorChar) >= 0) { + throw new IOException("Illegal value of: " + tmpRootPropName); + } + + // Set tmpRootDir = ${tmpbase}/${jnlp.applet.launcher.tmproot} + if (DEBUG) { + System.err.println("TempFileCache: Using existing value of: " + + tmpRootPropName + "=" + tmpRootPropValue); + } + tmpRootDir = new File(tmpBaseDir, tmpRootPropValue); + if (DEBUG) { + System.err.println("TempFileCache: tmpRootDir = " + tmpRootDir.getAbsolutePath()); + } + if (!tmpRootDir.isDirectory()) { + throw new IOException("Cannot access " + tmpRootDir); + } + } + } + if (DEBUG) { + System.err.println("------------------------------------------------------------------ (static ok: "+(!staticInitError)+")"); + } + } + + /** + * Called by the Reaper thread to delete old temp directories + * Only one of these threads will run per JVM invocation. + */ + private static void deleteOldTempDirs() { + if (DEBUG) { + System.err.println("TempFileCache: *** Reaper: deleteOldTempDirs in " + + tmpBaseDir.getAbsolutePath()); + } + + // enumerate list of jnl*.lck files, ignore our own jlnNNNN file + final String ourLockFile = tmpRootPropValue + ".lck"; + FilenameFilter lckFilter = new FilenameFilter() { + /* @Override */ + public boolean accept(File dir, String name) { + return name.endsWith(".lck") && !name.equals(ourLockFile); + } + }; + + // For each file <file>.lck in the list we will first try to lock + // <file>.tmp if that succeeds then we will try to lock <file>.lck + // (which should always succeed unless there is a problem). If we can + // get the lock on both files, then it must be an old installation, and + // we will delete it. + String[] fileNames = tmpBaseDir.list(lckFilter); + if (fileNames != null) { + for (int i = 0; i < fileNames.length; i++) { + String lckFileName = fileNames[i]; + String tmpDirName = lckFileName.substring(0, lckFileName.lastIndexOf(".lck")); + String tmpFileName = tmpDirName + ".tmp"; + + File lckFile = new File(tmpBaseDir, lckFileName); + File tmpFile = new File(tmpBaseDir, tmpFileName); + File tmpDir = new File(tmpBaseDir, tmpDirName); + + if (lckFile.exists() && tmpFile.exists() && tmpDir.isDirectory()) { + FileOutputStream tmpOut = null; + FileChannel tmpChannel = null; + FileLock tmpLock = null; + + try { + tmpOut = new FileOutputStream(tmpFile); + tmpChannel = tmpOut.getChannel(); + tmpLock = tmpChannel.tryLock(); + } catch (Exception ex) { + // Ignore exceptions + if (DEBUG) { + ex.printStackTrace(); + } + } + + if (tmpLock != null) { + FileOutputStream lckOut = null; + FileChannel lckChannel = null; + FileLock lckLock = null; + + try { + lckOut = new FileOutputStream(lckFile); + lckChannel = lckOut.getChannel(); + lckLock = lckChannel.tryLock(); + } catch (Exception ex) { + if (DEBUG) { + ex.printStackTrace(); + } + } + + if (lckLock != null) { + // Recursively remove the old tmpDir and all of + // its contents + removeAll(tmpDir); + + // Close the streams and delete the .lck and .tmp + // files. Note that there is a slight race condition + // in that another process could open a stream at + // the same time we are trying to delete it, which will + // prevent deletion, but we won't worry about it, since + // the worst that will happen is we might have an + // occasional 0-byte .lck or .tmp file left around + try { + lckOut.close(); + } catch (IOException ex) { + } + lckFile.delete(); + try { + tmpOut.close(); + } catch (IOException ex) { + } + tmpFile.delete(); + } else { + try { + // Close the file and channel for the *.lck file + if (lckOut != null) { + lckOut.close(); + } + // Close the file/channel and release the lock + // on the *.tmp file + tmpOut.close(); + tmpLock.release(); + } catch (IOException ex) { + if (DEBUG) { + ex.printStackTrace(); + } + } + } + } + } else { + if (DEBUG) { + System.err.println("TempFileCache: Skipping: " + tmpDir.getAbsolutePath()); + } + } + } + } + } + + /** + * Remove the specified file or directory. If "path" is a directory, then + * recursively remove all entries, then remove the directory itself. + */ + private static void removeAll(File path) { + if (DEBUG) { + System.err.println("TempFileCache: removeAll(" + path + ")"); + } + + if (path.isDirectory()) { + // Recursively remove all files/directories in this directory + File[] list = path.listFiles(); + if (list != null) { + for (int i = 0; i < list.length; i++) { + removeAll(list[i]); + } + } + } + + path.delete(); + } + + public LauncherTempFileCache () { + if (DEBUG) { + System.err.println("TempFileCache: new TempFileCache() --------------------- (static ok: "+(!staticInitError)+")"); + System.err.println("TempFileCache: Thread: "+Thread.currentThread().getName()+", CL 0x"+Integer.toHexString(LauncherTempFileCache.class.getClassLoader().hashCode())+", this 0x"+Integer.toHexString(hashCode())); + } + if(!staticInitError) { + try { + createTmpDir(); + } catch (Exception ex) { + ex.printStackTrace(); + initError = true; + } + } + if (DEBUG) { + System.err.println("tempDir: "+individualTmpDir+" (ok: "+(!initError)+")"); + System.err.println("----------------------------------------------------------"); + } + } + + /** + * @return true is static and object initialization was successful + */ + public boolean isValid() { return !staticInitError && !initError; } + + /** + * Base temp directory used by TempFileCache. + * Lifecycle: For all JVMs, ClassLoader and times. + * + * This is set to: + * + * ${java.io.tmpdir}/<tmpDirPrefix> + * + * + * @return + */ + public File getBaseDir() { return tmpBaseDir; } + + /** + * Root temp directory for this JVM instance. Used to store individual + * directories. + * + * Lifecycle: For all JVMs and ClassLoader + * + * <tmpBaseDir>/<tmpRootPropValue> + * + * Use Case: Per ClassLoader files, eg. native libraries. + * + * Old temp directories are cleaned up the next time a JVM is launched that + * uses TempFileCache. + * + * + * @return + */ + public File getRootDir() { return tmpRootDir; } + + /** + * Temporary directory for individual files (eg. native libraries of one ClassLoader instance). + * The directory name is: + * + * Lifecycle: Within each JVM .. use case dependent, ie. per ClassLoader + * + * <tmpRootDir>/jlnMMMMM + * + * where jlnMMMMM is the unique filename created by File.createTempFile() + * without the ".tmp" extension. + * + * + * @return + */ + public File getTempDir() { return individualTmpDir; } + + + /** + * Create the temp directory in tmpRootDir. To do this, we create a temp + * file with a ".tmp" extension, and then create a directory of the + * same name but without the ".tmp". The temp file, directory, and all + * files in the directory will be reaped the next time this is started. + * We avoid deleteOnExit, because it doesn't work reliably. + */ + private void createTmpDir() throws IOException { + File tmpFile = File.createTempFile("jln", ".tmp", tmpRootDir); + String tmpFileName = tmpFile.getAbsolutePath(); + String tmpDirName = tmpFileName.substring(0, tmpFileName.lastIndexOf(".tmp")); + individualTmpDir = new File(tmpDirName); + if (!individualTmpDir.mkdir()) { + throw new IOException("Cannot create " + individualTmpDir); + } + } +} diff --git a/src/java/jogamp/android/launcher/LauncherUtil.java b/src/java/jogamp/android/launcher/LauncherUtil.java new file mode 100644 index 0000000..63452b7 --- /dev/null +++ b/src/java/jogamp/android/launcher/LauncherUtil.java @@ -0,0 +1,320 @@ +/** + * Copyright 2012 JogAmp Community. All rights reserved. + * + * Redistribution and use in source and binary forms, with or without modification, are + * permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this list of + * conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, this list + * of conditions and the following disclaimer in the documentation and/or other materials + * provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY JogAmp Community ``AS IS'' AND ANY EXPRESS OR IMPLIED + * WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND + * FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL JogAmp Community OR + * CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR + * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON + * ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING + * NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF + * ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + * + * The views and conclusions contained in the software and documentation are those of the + * authors and should not be interpreted as representing official policies, either expressed + * or implied, of JogAmp Community. + */ +package jogamp.android.launcher; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.Iterator; +import java.util.List; +import java.util.Map; + +import android.app.Activity; +import android.content.Intent; +import android.net.Uri; +import android.os.Bundle; +import android.util.Log; + +/** + * Helper class to parse Uri's and programmatically add package names and properties to create an Uri or Intend. + * <p> + * The order of the Uri segments (any arguments) is preserved. + * </p> + */ +public class LauncherUtil { + + /** Default launch mode. */ + public static final String LAUNCH_ACTIVITY_NORMAL = "org.jogamp.launcher.action.LAUNCH_ACTIVITY_NORMAL"; + + /** Transparent launch mode. Note: This seems to be required to achieve translucency, since setTheme(..) doesn't work. */ + public static final String LAUNCH_ACTIVITY_TRANSPARENT = "org.jogamp.launcher.action.LAUNCH_ACTIVITY_TRANSPARENT"; + + /** FIXME: TODO */ + public static final String LAUNCH_MAIN = "org.jogamp.launcher.action.LAUNCH_MAIN"; + + /** FIXME: TODO */ + public static final String LAUNCH_JUNIT = "org.jogamp.launcher.action.LAUNCH_JUNIT"; + + /** The protocol <code>launch</code> */ + public static final String SCHEME = "launch"; + + /** The host <code>jogamp.org</code> */ + public static final String HOST = "jogamp.org"; + + static final String PKG = "pkg"; + + public static abstract class BaseActivityLauncher extends Activity { + final OrderedProperties props = new OrderedProperties(); + + /** + * Returns the default {@link LauncherUtil#LAUNCH_ACTIVITY_NORMAL} action. + * <p> + * Should be overridden for other action, eg. {@link LauncherUtil#LAUNCH_ACTIVITY_TRANSPARENT}. + * </p> + */ + public String getAction() { return LAUNCH_ACTIVITY_NORMAL; } + + /** + * Returns the properties, which are being propagated to the target activity. + * <p> + * Maybe be used to set custom properties. + * </p> + */ + public final OrderedProperties getProperties() { return props; } + + /** Custom initialization hook which can be overriden to setup data, e.g. fill the properties retrieved by {@link #getProperties()}. */ + public void init() { } + + /** Returns true if this launcher activity shall end after starting the downstream activity. Defaults to <code>true</code>, override to change behavior. */ + public boolean finishAfterDelegate() { return true; } + + /** Must return the downstream Activity class name */ + public abstract String getActivityName(); + + /** Must return a list of required packages, at least one. */ + public abstract List<String> getPackages(); + + @Override + public void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + init(); + + final DataSet data = new DataSet(); + data.setActivityName(getActivityName()); + data.addAllPackages(getPackages()); + data.addAllProperties(props); + + final Intent intent = LauncherUtil.getIntent(getAction(), data); + Log.d(getClass().getSimpleName(), "Launching Activity: "+intent); + startActivity (intent); + + if(finishAfterDelegate()) { + finish(); // done + } + } + } + + public static class OrderedProperties { + HashMap<String, String> map = new HashMap<String, String>(); + ArrayList<String> keyList = new ArrayList<String>(); + + public final void setProperty(String key, String value) { + if(key.equals(PKG)) { + throw new IllegalArgumentException("Illegal property key, '"+PKG+"' is reserved"); + } + final String oval = map.put(key, value); + if(null != oval) { + map.put(key, oval); // restore + throw new IllegalArgumentException("Property overwriting not allowed: "+key+": "+oval+" -> "+value); + } + keyList.add(key); // new key + } + + public final void addAll(OrderedProperties props) { + Iterator<String> argKeys = props.keyList.iterator(); + while(argKeys.hasNext()) { + final String key = argKeys.next(); + setProperty(key, props.map.get(key)); + } + } + + public final void setSystemProperties() { + Iterator<String> argKeys = keyList.iterator(); + while(argKeys.hasNext()) { + final String key = argKeys.next(); + System.setProperty(key, map.get(key)); + } + } + + public final String getProperty(String key) { return map.get(key); } + public final Map<String, String> getProperties() { return map; } + + /** Returns the list of property keys in the order, as they were added. */ + public final List<String> getPropertyKeys() { return keyList; } + } + + public static class DataSet { + static final char SLASH = '/'; + static final char QMARK = '?'; + static final char AMPER = '&'; + static final char ASSIG = '='; + static final String COLSLASH2 = "://"; + static final String EMPTY = ""; + + String activityName = null; + ArrayList<String> packages = new ArrayList<String>(); + OrderedProperties properties = new OrderedProperties(); + + public final void setActivityName(String name) { activityName = name; } + public final String getActivityName() { return activityName; } + + public final void addPackage(String p) { + packages.add(p); + } + public final void addAllPackages(List<String> plist) { + packages.addAll(plist); + } + public final List<String> getPackages() { return packages; } + + public final void setProperty(String key, String value) { + properties.setProperty(key, value); + } + public final void addAllProperties(OrderedProperties props) { + properties.addAll(props); + } + public final void setSystemProperties() { + properties.setSystemProperties(); + } + public final String getProperty(String key) { return properties.getProperty(key); } + public final OrderedProperties getProperties() { return properties; } + public final List<String> getPropertyKeys() { return properties.getPropertyKeys(); } + + public final Uri getUri() { + StringBuilder sb = new StringBuilder(); + sb.append(SCHEME).append(COLSLASH2).append(HOST).append(SLASH).append(getActivityName()); + boolean needsSep = false; + if(packages.size()>0) { + sb.append(QMARK); + for(int i=0; i<packages.size(); i++) { + if(needsSep) { + sb.append(AMPER); + } + sb.append(PKG).append(ASSIG).append(packages.get(i)); + needsSep = true; + } + } + Iterator<String> argKeys = properties.keyList.iterator(); + while(argKeys.hasNext()) { + if(needsSep) { + sb.append(AMPER); + } + final String key = argKeys.next(); + sb.append(key).append(ASSIG).append(properties.map.get(key)); + needsSep = true; + } + return Uri.parse(sb.toString()); + } + + public static final DataSet create(Uri uri) { + if(!uri.getScheme().equals(SCHEME)) { + return null; + } + if(!uri.getHost().equals(HOST)) { + return null; + } + DataSet data = new DataSet(); + { + String an = uri.getPath(); + if(SLASH == an.charAt(0)) { + an = an.substring(1); + } + if(SLASH == an.charAt(an.length()-1)) { + an = an.substring(0, an.length()-1); + } + data.setActivityName(an); + } + + final String q = uri.getQuery(); + final int q_l = q.length(); + int q_e = -1; + while(q_e < q_l) { + int q_b = q_e + 1; // next term + q_e = q.indexOf(AMPER, q_b); + if(0 == q_e) { + // single seperator + continue; + } + if(0 > q_e) { + // end + q_e = q_l; + } + // n-part + final String part = q.substring(q_b, q_e); + final int assignment = part.indexOf(ASSIG); + if(0 < assignment) { + // assignment + final String k = part.substring(0, assignment); + final String v = part.substring(assignment+1); + if(k.equals(PKG)) { + if(v.length()==0) { + throw new IllegalArgumentException("Empty package name: part <"+part+">, query <"+q+"> of "+uri); + } + data.addPackage(v); + } else { + data.setProperty(k, v); + } + } else { + // property key only + if(part.equals(PKG)) { + throw new IllegalArgumentException("Empty package name: part <"+part+">, query <"+q+"> of "+uri); + } + data.setProperty(part, EMPTY); + } + } + data.validate(); + return data; + } + + public final void validate() { + if(null == activityName) { + throw new RuntimeException("Activity is not NULL"); + } + if(packages.size() == 0) { + throw new RuntimeException("Empty package list"); + } + } + } + + public final static Intent getIntent(String action, DataSet data) { + data.validate(); + return new Intent(action, data.getUri()); + } + + public static void main(String[] args) { + if(args.length==0) { + args = new String[] { + SCHEME+"://"+HOST+"/com.jogamp.TestActivity?"+PKG+"=jogamp.pack1&"+PKG+"=javax.pack2&"+PKG+"=com.jogamp.pack3&jogamp.common.debug=true&com.jogamp.test=false", + SCHEME+"://"+HOST+"/com.jogamp.TestActivity?"+PKG+"=jogamp.pack1&jogamp.common.debug=true&com.jogamp.test=false", + SCHEME+"://"+HOST+"/com.jogamp.TestActivity?"+PKG+"=jogamp.pack1" + }; + } + for(int i=0; i<args.length; i++) { + String uri_s = args[i]; + Uri uri0 = Uri.parse(uri_s); + DataSet data = DataSet.create(uri0); + if(null == data) { + System.err.println("Error: NULL JogAmpLauncherUtil: <"+uri_s+"> -> "+uri0+" -> NULL"); + } + Uri uri1 = data.getUri(); + if(!uri0.equals(uri1)) { + System.err.println("Error: Not equal: <"+uri_s+"> -> "+uri0+" -> "+uri1); + } + } + } + +} |