ruboto 0.5.4 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- data/Gemfile.lock +9 -9
- data/README.md +2 -0
- data/Rakefile +71 -7
- data/assets/Rakefile +2 -407
- data/assets/libs/dexmaker20120305.jar +0 -0
- data/assets/rakelib/ruboto.rake +428 -0
- data/assets/samples/sample_broadcast_receiver.rb +7 -3
- data/assets/samples/sample_broadcast_receiver_test.rb +47 -1
- data/assets/src/RubotoActivity.java +6 -2
- data/assets/src/org/ruboto/EntryPointActivity.java +2 -2
- data/assets/src/org/ruboto/Script.java +91 -24
- data/assets/src/org/ruboto/test/ActivityTest.java +27 -24
- data/assets/src/org/ruboto/test/InstrumentationTestRunner.java +42 -8
- data/assets/src/ruboto/activity.rb +1 -1
- data/assets/src/ruboto/base.rb +17 -6
- data/assets/src/ruboto/generate.rb +458 -0
- data/assets/src/ruboto/legacy.rb +9 -12
- data/assets/src/ruboto/widget.rb +9 -1
- data/lib/java_class_gen/android_api.xml +1 -1
- data/lib/ruboto.rb +1 -2
- data/lib/ruboto/commands/base.rb +19 -4
- data/lib/ruboto/sdk_versions.rb +12 -0
- data/lib/ruboto/util/build.rb +10 -11
- data/lib/ruboto/util/update.rb +150 -51
- data/lib/ruboto/util/verify.rb +6 -4
- data/lib/ruboto/util/xml_element.rb +2 -2
- data/lib/ruboto/version.rb +1 -1
- data/test/activity/option_menu_activity.rb +5 -1
- data/test/activity/psych_activity.rb +11 -6
- data/test/activity/stack_activity_test.rb +13 -5
- data/test/app_test_methods.rb +4 -3
- data/test/broadcast_receiver_test.rb +86 -0
- data/test/minimal_app_test.rb +27 -19
- data/test/rake_test.rb +13 -2
- data/test/ruboto_gen_test.rb +17 -3
- data/test/ruboto_update_test.rb +24 -2
- data/test/service_test.rb +1 -1
- data/test/test_helper.rb +134 -62
- data/test/update_test_methods.rb +40 -14
- data/test/updated_example_test_methods.rb +41 -0
- metadata +12 -6
@@ -17,7 +17,7 @@ public class THE_RUBOTO_CLASS THE_ACTION THE_ANDROID_CLASS {
|
|
17
17
|
private String scriptName;
|
18
18
|
private String remoteVariable = null;
|
19
19
|
private Object[] args;
|
20
|
-
private Bundle configBundle;
|
20
|
+
private Bundle configBundle = null;
|
21
21
|
|
22
22
|
THE_CONSTANTS
|
23
23
|
|
@@ -85,7 +85,7 @@ THE_CONSTANTS
|
|
85
85
|
if (scriptName != null) {
|
86
86
|
Script.setScriptFilename(getClass().getClassLoader().getResource(scriptName).getPath());
|
87
87
|
Script.execute(new Script(scriptName).getContents());
|
88
|
-
} else {
|
88
|
+
} else if (configBundle != null) {
|
89
89
|
// TODO: Why doesn't this work?
|
90
90
|
// Script.callMethod(this, "initialize_ruboto");
|
91
91
|
Script.execute("$activity.initialize_ruboto");
|
@@ -99,6 +99,10 @@ THE_CONSTANTS
|
|
99
99
|
}
|
100
100
|
}
|
101
101
|
|
102
|
+
public boolean rubotoAttachable() {
|
103
|
+
return true;
|
104
|
+
}
|
105
|
+
|
102
106
|
/****************************************************************************************
|
103
107
|
*
|
104
108
|
* Generated Methods
|
@@ -25,7 +25,7 @@ public class EntryPointActivity extends org.ruboto.RubotoActivity {
|
|
25
25
|
private ProgressDialog loadingDialog;
|
26
26
|
private boolean dialogCancelled = false;
|
27
27
|
private BroadcastReceiver receiver;
|
28
|
-
|
28
|
+
protected boolean appStarted = false;
|
29
29
|
|
30
30
|
public void onCreate(Bundle bundle) {
|
31
31
|
Log.d("RUBOTO", "onCreate: ");
|
@@ -154,7 +154,7 @@ public class EntryPointActivity extends org.ruboto.RubotoActivity {
|
|
154
154
|
}
|
155
155
|
}
|
156
156
|
|
157
|
-
|
157
|
+
protected void fireRubotoActivity() {
|
158
158
|
if(appStarted) return;
|
159
159
|
appStarted = true;
|
160
160
|
Log.i("RUBOTO", "Starting activity");
|
@@ -33,6 +33,8 @@ public class Script {
|
|
33
33
|
|
34
34
|
private String name = null;
|
35
35
|
private static Object ruby;
|
36
|
+
private static boolean isDebugBuild = false;
|
37
|
+
private static PrintStream output = null;
|
36
38
|
private static boolean initialized = false;
|
37
39
|
|
38
40
|
private static String localContextScope = "SINGLETON";
|
@@ -40,6 +42,7 @@ public class Script {
|
|
40
42
|
|
41
43
|
public static final String TAG = "RUBOTO"; // for logging
|
42
44
|
private static String JRUBY_VERSION;
|
45
|
+
private static String RUBOTO_CORE_VERSION_NAME;
|
43
46
|
|
44
47
|
/*************************************************************************************************
|
45
48
|
*
|
@@ -69,19 +72,29 @@ public class Script {
|
|
69
72
|
return initialized;
|
70
73
|
}
|
71
74
|
|
75
|
+
public static boolean usesPlatformApk() {
|
76
|
+
return RUBOTO_CORE_VERSION_NAME != null;
|
77
|
+
}
|
78
|
+
|
79
|
+
public static String getPlatformVersionName() {
|
80
|
+
return RUBOTO_CORE_VERSION_NAME;
|
81
|
+
}
|
82
|
+
|
72
83
|
public static synchronized boolean setUpJRuby(Context appContext) {
|
73
|
-
return setUpJRuby(appContext, System.out);
|
84
|
+
return setUpJRuby(appContext, output == null ? System.out : output);
|
74
85
|
}
|
75
86
|
|
76
87
|
public static synchronized boolean setUpJRuby(Context appContext, PrintStream out) {
|
77
88
|
if (!initialized) {
|
78
|
-
|
79
|
-
|
89
|
+
setDebugBuild(appContext);
|
90
|
+
Log.d(TAG, "Setting up JRuby runtime (" + (isDebugBuild ? "DEBUG" : "RELEASE") + ")");
|
91
|
+
System.setProperty("jruby.bytecode.version", "1.6");
|
80
92
|
System.setProperty("jruby.interfaces.useProxy", "true");
|
81
93
|
System.setProperty("jruby.management.enabled", "false");
|
82
94
|
System.setProperty("jruby.objectspace.enabled", "false");
|
83
95
|
System.setProperty("jruby.thread.pooling", "true");
|
84
96
|
System.setProperty("jruby.native.enabled", "false");
|
97
|
+
// System.setProperty("jruby.compat.version", "RUBY1_8"); // RUBY1_9 is the default
|
85
98
|
|
86
99
|
// Uncomment these to debug Ruby source loading
|
87
100
|
// System.setProperty("jruby.debug.loadService", "true");
|
@@ -102,9 +115,14 @@ public class Script {
|
|
102
115
|
} catch (ClassNotFoundException e1) {
|
103
116
|
String packageName = "org.ruboto.core";
|
104
117
|
try {
|
105
|
-
|
106
|
-
|
107
|
-
|
118
|
+
PackageInfo pkgInfo = appContext.getPackageManager().getPackageInfo(packageName, 0);
|
119
|
+
apkName = pkgInfo.applicationInfo.sourceDir;
|
120
|
+
RUBOTO_CORE_VERSION_NAME = pkgInfo.versionName;
|
121
|
+
} catch (PackageManager.NameNotFoundException e2) {
|
122
|
+
out.println("JRuby not found in local APK:");
|
123
|
+
e1.printStackTrace(out);
|
124
|
+
out.println("JRuby not found in platform APK:");
|
125
|
+
e2.printStackTrace(out);
|
108
126
|
return false;
|
109
127
|
}
|
110
128
|
|
@@ -169,10 +187,10 @@ public class Script {
|
|
169
187
|
callScriptingContainerMethod(Void.class, "setCurrentDirectory", defaultCurrentDir);
|
170
188
|
|
171
189
|
if (out != null) {
|
172
|
-
|
173
|
-
|
174
|
-
|
175
|
-
|
190
|
+
output = out;
|
191
|
+
setOutputStream(out);
|
192
|
+
} else if (output != null) {
|
193
|
+
setOutputStream(output);
|
176
194
|
}
|
177
195
|
|
178
196
|
String jrubyHome = "file:" + apkName + "!";
|
@@ -204,6 +222,29 @@ public class Script {
|
|
204
222
|
return initialized;
|
205
223
|
}
|
206
224
|
|
225
|
+
public static void setOutputStream(PrintStream out) {
|
226
|
+
if (ruby == null) {
|
227
|
+
output = out;
|
228
|
+
} else {
|
229
|
+
try {
|
230
|
+
Method setOutputMethod = ruby.getClass().getMethod("setOutput", PrintStream.class);
|
231
|
+
setOutputMethod.invoke(ruby, out);
|
232
|
+
Method setErrorMethod = ruby.getClass().getMethod("setError", PrintStream.class);
|
233
|
+
setErrorMethod.invoke(ruby, out);
|
234
|
+
} catch (IllegalArgumentException e) {
|
235
|
+
handleInitException(e);
|
236
|
+
} catch (SecurityException e) {
|
237
|
+
handleInitException(e);
|
238
|
+
} catch (IllegalAccessException e) {
|
239
|
+
handleInitException(e);
|
240
|
+
} catch (InvocationTargetException e) {
|
241
|
+
handleInitException(e);
|
242
|
+
} catch (NoSuchMethodException e) {
|
243
|
+
handleInitException(e);
|
244
|
+
}
|
245
|
+
}
|
246
|
+
}
|
247
|
+
|
207
248
|
private static void handleInitException(Exception e) {
|
208
249
|
Log.e(TAG, "Exception starting JRuby");
|
209
250
|
Log.e(TAG, e.getMessage() != null ? e.getMessage() : e.getClass().getName());
|
@@ -252,7 +293,11 @@ public class Script {
|
|
252
293
|
} catch (IllegalAccessException iae) {
|
253
294
|
throw new RuntimeException(iae);
|
254
295
|
} catch (java.lang.reflect.InvocationTargetException ite) {
|
255
|
-
|
296
|
+
if (isDebugBuild) {
|
297
|
+
throw ((RuntimeException) ite.getCause());
|
298
|
+
} else {
|
299
|
+
return null;
|
300
|
+
}
|
256
301
|
}
|
257
302
|
}
|
258
303
|
|
@@ -315,7 +360,7 @@ public class Script {
|
|
315
360
|
}
|
316
361
|
|
317
362
|
private static List<String> getLoadPath() {
|
318
|
-
return callScriptingContainerMethod(List.class, "getLoadPaths");
|
363
|
+
return (List<String>)callScriptingContainerMethod(List.class, "getLoadPaths");
|
319
364
|
}
|
320
365
|
|
321
366
|
public static Boolean configDir(String scriptsDir) {
|
@@ -371,21 +416,20 @@ public class Script {
|
|
371
416
|
}
|
372
417
|
}
|
373
418
|
|
374
|
-
private static
|
419
|
+
private static void setDebugBuild(Context context) {
|
375
420
|
PackageManager pm = context.getPackageManager();
|
376
421
|
PackageInfo pi;
|
377
422
|
try {
|
378
423
|
pi = pm.getPackageInfo(context.getPackageName(), 0);
|
379
|
-
|
424
|
+
isDebugBuild = ((pi.applicationInfo.flags & ApplicationInfo.FLAG_DEBUGGABLE) != 0);
|
380
425
|
} catch (NameNotFoundException e) {
|
381
|
-
|
426
|
+
isDebugBuild = false;
|
382
427
|
}
|
383
|
-
|
384
428
|
}
|
385
429
|
|
386
430
|
private static String scriptsDirName(Context context) {
|
387
431
|
File storageDir = null;
|
388
|
-
if (isDebugBuild
|
432
|
+
if (isDebugBuild) {
|
389
433
|
|
390
434
|
// FIXME(uwe): Simplify this as soon as we drop support for android-7 or JRuby 1.5.6 or JRuby 1.6.2
|
391
435
|
Log.i(TAG, "JRuby VERSION: " + JRUBY_VERSION);
|
@@ -474,7 +518,7 @@ public class Script {
|
|
474
518
|
*/
|
475
519
|
|
476
520
|
public static String getScriptFilename() {
|
477
|
-
return callScriptingContainerMethod(String.class, "getScriptFilename");
|
521
|
+
return (String)callScriptingContainerMethod(String.class, "getScriptFilename");
|
478
522
|
}
|
479
523
|
|
480
524
|
public static void setScriptFilename(String name) {
|
@@ -496,6 +540,9 @@ public class Script {
|
|
496
540
|
throw new RuntimeException(iae);
|
497
541
|
} catch (java.lang.reflect.InvocationTargetException ite) {
|
498
542
|
printStackTrace(ite);
|
543
|
+
if (isDebugBuild) {
|
544
|
+
throw new RuntimeException(ite);
|
545
|
+
}
|
499
546
|
}
|
500
547
|
}
|
501
548
|
|
@@ -522,14 +569,34 @@ public class Script {
|
|
522
569
|
return null;
|
523
570
|
}
|
524
571
|
|
525
|
-
|
526
|
-
|
527
|
-
|
572
|
+
@SuppressWarnings("unchecked")
|
573
|
+
public static <T> T callMethod(Object receiver, String methodName, Object arg, Class<T> returnType) {
|
574
|
+
try {
|
575
|
+
Method callMethodMethod = ruby.getClass().getMethod("callMethod", Object.class, String.class, Object.class, Class.class);
|
576
|
+
return (T) callMethodMethod.invoke(ruby, receiver, methodName, arg, returnType);
|
577
|
+
} catch (NoSuchMethodException nsme) {
|
578
|
+
throw new RuntimeException(nsme);
|
579
|
+
} catch (IllegalAccessException iae) {
|
580
|
+
throw new RuntimeException(iae);
|
581
|
+
} catch (java.lang.reflect.InvocationTargetException ite) {
|
582
|
+
printStackTrace(ite);
|
583
|
+
}
|
584
|
+
return null;
|
528
585
|
}
|
529
586
|
|
530
|
-
|
531
|
-
|
532
|
-
|
587
|
+
@SuppressWarnings("unchecked")
|
588
|
+
public static <T> T callMethod(Object receiver, String methodName, Class<T> returnType) {
|
589
|
+
try {
|
590
|
+
Method callMethodMethod = ruby.getClass().getMethod("callMethod", Object.class, String.class, Class.class);
|
591
|
+
return (T) callMethodMethod.invoke(ruby, receiver, methodName, returnType);
|
592
|
+
} catch (NoSuchMethodException nsme) {
|
593
|
+
throw new RuntimeException(nsme);
|
594
|
+
} catch (IllegalAccessException iae) {
|
595
|
+
throw new RuntimeException(iae);
|
596
|
+
} catch (java.lang.reflect.InvocationTargetException ite) {
|
597
|
+
printStackTrace(ite);
|
598
|
+
}
|
599
|
+
return null;
|
533
600
|
}
|
534
601
|
|
535
602
|
private static void printStackTrace(Throwable t) {
|
@@ -19,44 +19,47 @@ public class ActivityTest extends ActivityInstrumentationTestCase2 {
|
|
19
19
|
private final Object setup;
|
20
20
|
private final Object block;
|
21
21
|
private final String filename;
|
22
|
+
private final boolean onUiThread;
|
22
23
|
|
23
|
-
public ActivityTest(Class activityClass, String filename, Object setup, String name, Object block) {
|
24
|
+
public ActivityTest(Class activityClass, String filename, Object setup, String name, boolean onUiThread, Object block) {
|
24
25
|
super(activityClass.getPackage().getName(), activityClass);
|
25
26
|
this.filename = filename;
|
26
27
|
this.setup = setup;
|
27
28
|
setName(filename + "#" + name);
|
29
|
+
this.onUiThread = onUiThread;
|
28
30
|
this.block = block;
|
29
31
|
Log.i(getClass().getName(), "Instance: " + getName());
|
30
32
|
}
|
31
33
|
|
32
34
|
public void runTest() throws Exception {
|
33
|
-
|
34
|
-
|
35
|
-
|
36
|
-
Log.i(getClass().getName(), "
|
37
|
-
|
38
|
-
|
39
|
-
|
40
|
-
runTestOnUiThread(new Runnable() {
|
41
|
-
public void run() {
|
42
|
-
String oldFile = Script.getScriptFilename();
|
35
|
+
try {
|
36
|
+
Log.i(getClass().getName(), "runTest: " + getName());
|
37
|
+
final Activity activity = getActivity();
|
38
|
+
Log.i(getClass().getName(), "Activity OK");
|
39
|
+
Runnable testRunnable = new Runnable() {
|
40
|
+
public void run() {
|
41
|
+
String oldFile = Script.getScriptFilename();
|
43
42
|
|
44
|
-
|
45
|
-
|
46
|
-
|
47
|
-
|
43
|
+
Log.i(getClass().getName(), "calling setup");
|
44
|
+
Script.setScriptFilename(filename);
|
45
|
+
Script.callMethod(setup, "call", activity);
|
46
|
+
Log.i(getClass().getName(), "setup ok");
|
48
47
|
|
49
|
-
|
50
|
-
|
51
|
-
|
52
|
-
|
53
|
-
|
54
|
-
|
55
|
-
|
48
|
+
Script.setScriptFilename(filename);
|
49
|
+
Script.callMethod(block, "call", activity);
|
50
|
+
Script.setScriptFilename(oldFile);
|
51
|
+
}
|
52
|
+
};
|
53
|
+
if (onUiThread) {
|
54
|
+
runTestOnUiThread(testRunnable);
|
55
|
+
} else {
|
56
|
+
testRunnable.run();
|
56
57
|
}
|
57
58
|
Log.i(getClass().getName(), "runTest ok");
|
58
|
-
}
|
59
|
-
|
59
|
+
} catch (Throwable t) {
|
60
|
+
AssertionFailedError afe = new AssertionFailedError("Exception running test.");
|
61
|
+
afe.initCause(t);
|
62
|
+
throw afe;
|
60
63
|
}
|
61
64
|
}
|
62
65
|
|
@@ -15,10 +15,12 @@ import java.net.URLDecoder;
|
|
15
15
|
import java.util.ArrayList;
|
16
16
|
import java.util.Arrays;
|
17
17
|
import java.util.Collections;
|
18
|
+
import java.util.concurrent.atomic.AtomicBoolean;
|
18
19
|
import java.util.Enumeration;
|
19
20
|
import java.util.jar.JarFile;
|
20
21
|
import java.util.jar.JarEntry;
|
21
22
|
import java.util.List;
|
23
|
+
import java.util.Map;
|
22
24
|
import junit.framework.Test;
|
23
25
|
import junit.framework.TestCase;
|
24
26
|
import junit.framework.TestSuite;
|
@@ -34,15 +36,37 @@ public class InstrumentationTestRunner extends android.test.InstrumentationTestR
|
|
34
36
|
public TestSuite getAllTests() {
|
35
37
|
Log.i(getClass().getName(), "Finding test scripts");
|
36
38
|
suite = new TestSuite("Sweet");
|
39
|
+
String loadStep = "Setup JRuby";
|
37
40
|
|
38
41
|
try {
|
39
|
-
|
42
|
+
final AtomicBoolean JRubyLoadedOk = new AtomicBoolean();
|
43
|
+
|
44
|
+
// TODO(uwe): Running with large stack is currently only needed when running with JRuby 1.7.0 and android-10
|
45
|
+
// TODO(uwe): Simplify when we stop support for JRuby 1.7.0 or android-10
|
46
|
+
Thread t = new Thread(null, new Runnable() {
|
47
|
+
public void run() {
|
48
|
+
JRubyLoadedOk.set(Script.setUpJRuby(getTargetContext()));
|
49
|
+
}
|
50
|
+
}, "Setup JRuby from instrumentation test runner", 64 * 1024);
|
51
|
+
try {
|
52
|
+
t.start();
|
53
|
+
t.join();
|
54
|
+
} catch(InterruptedException ie) {
|
55
|
+
Thread.currentThread().interrupt();
|
56
|
+
throw new RuntimeException("Interrupted starting JRuby", ie);
|
57
|
+
}
|
58
|
+
// TODO end
|
59
|
+
|
60
|
+
if (JRubyLoadedOk.get()) {
|
61
|
+
loadStep = "Setup global variables";
|
40
62
|
Script.defineGlobalVariable("$runner", this);
|
41
63
|
Script.defineGlobalVariable("$test", this);
|
42
64
|
Script.defineGlobalVariable("$suite", suite);
|
43
65
|
|
66
|
+
loadStep = "Load test helper";
|
44
67
|
loadScript("test_helper.rb");
|
45
68
|
|
69
|
+
loadStep = "Get app test source dir";
|
46
70
|
String test_apk_path = getContext().getPackageManager().getApplicationInfo(getContext().getPackageName(), 0).sourceDir;
|
47
71
|
JarFile jar = new JarFile(test_apk_path);
|
48
72
|
Enumeration<JarEntry> entries = jar.entries();
|
@@ -53,17 +77,18 @@ public class InstrumentationTestRunner extends android.test.InstrumentationTestR
|
|
53
77
|
continue;
|
54
78
|
}
|
55
79
|
if (name.equals("test_helper.rb")) continue;
|
80
|
+
loadStep = "Load " + name;
|
56
81
|
loadScript(name);
|
57
82
|
}
|
58
83
|
} else {
|
59
|
-
addError(suite, new RuntimeException("Ruboto Core platform is missing"));
|
84
|
+
addError(suite, loadStep, new RuntimeException("Ruboto Core platform is missing"));
|
60
85
|
}
|
61
86
|
} catch (android.content.pm.PackageManager.NameNotFoundException e) {
|
62
|
-
addError(suite, e);
|
87
|
+
addError(suite, loadStep, e);
|
63
88
|
} catch (IOException e) {
|
64
|
-
addError(suite, e);
|
89
|
+
addError(suite, loadStep, e);
|
65
90
|
} catch (RuntimeException e) {
|
66
|
-
addError(suite, e);
|
91
|
+
addError(suite, loadStep, e);
|
67
92
|
}
|
68
93
|
return suite;
|
69
94
|
}
|
@@ -77,18 +102,27 @@ public class InstrumentationTestRunner extends android.test.InstrumentationTestR
|
|
77
102
|
}
|
78
103
|
|
79
104
|
public void test(String name, Object block) {
|
105
|
+
test(name, null, block);
|
106
|
+
}
|
107
|
+
|
108
|
+
public void test(String name, Map options, Object block) {
|
109
|
+
// FIXME(uwe): Remove when we stop supporting Android 2.2
|
80
110
|
if (android.os.Build.VERSION.SDK_INT <= 8) {
|
81
111
|
name ="runTest";
|
82
112
|
}
|
83
|
-
|
113
|
+
// FIXME end
|
114
|
+
|
115
|
+
boolean runOnUiThread = options == null || options.get("ui") == "true";
|
116
|
+
|
117
|
+
Test test = new ActivityTest(activityClass, Script.getScriptFilename(), setup, name, runOnUiThread, block);
|
84
118
|
suite.addTest(test);
|
85
119
|
Log.d(getClass().getName(), "Made test instance: " + test);
|
86
120
|
}
|
87
121
|
|
88
|
-
private void addError(TestSuite suite, Throwable t) {
|
122
|
+
private void addError(TestSuite suite, String loadStep, Throwable t) {
|
89
123
|
Throwable cause = t;
|
90
124
|
while(cause != null) {
|
91
|
-
Log.e(getClass().getName(), "Exception loading tests: " + cause);
|
125
|
+
Log.e(getClass().getName(), "Exception loading tests (" + loadStep + "): " + cause);
|
92
126
|
t = cause;
|
93
127
|
cause = t.getCause();
|
94
128
|
}
|
@@ -35,7 +35,7 @@ module Ruboto
|
|
35
35
|
$context_init_block = block
|
36
36
|
$new_context_global = global_variable_name
|
37
37
|
|
38
|
-
if @initialized or (self == $activity && !$activity.
|
38
|
+
if @initialized or (self == $activity && !$activity.rubotoAttachable)
|
39
39
|
b = Java::android.os.Bundle.new
|
40
40
|
b.putInt("Theme", theme) if theme
|
41
41
|
|