Merged upstream 4.7 into downstream 4.6
🔗 GHA / 📊 Static checks (push) Has been cancelled
🔗 GHA / 🤖 Android (push) Has been cancelled
🔗 GHA / 🍏 iOS (push) Has been cancelled
🔗 GHA / 🐧 Linux (push) Has been cancelled
🔗 GHA / 🍎 macOS (push) Has been cancelled
🔗 GHA / 🏁 Windows (push) Has been cancelled
🔗 GHA / 🌐 Web (push) Has been cancelled

This commit is contained in:
2026-06-27 13:51:14 -04:00
5981 changed files with 851233 additions and 341989 deletions
+20 -20
View File
@@ -27,8 +27,6 @@ allprojects {
}
configurations {
// Initializes a placeholder for the devImplementation dependency configuration.
devImplementation {}
// Initializes a placeholder for the monoImplementation dependency configuration.
monoImplementation {}
}
@@ -43,6 +41,7 @@ dependencies {
implementation "androidx.fragment:fragment:$versions.fragmentVersion"
implementation "androidx.core:core-splashscreen:$versions.splashscreenVersion"
implementation "androidx.documentfile:documentfile:$versions.documentfileVersion"
if (rootProject.findProject(":lib")) {
implementation project(":lib")
@@ -52,15 +51,32 @@ dependencies {
// Godot gradle build mode. In this scenario this project is the only one around and the Godot
// library is available through the pre-generated godot-lib.*.aar android archive files.
debugImplementation fileTree(dir: 'libs/debug', include: ['**/*.jar', '*.aar'])
devImplementation fileTree(dir: 'libs/dev', include: ['**/*.jar', '*.aar'])
releaseImplementation fileTree(dir: 'libs/release', include: ['**/*.jar', '*.aar'])
}
// Godot user plugins remote dependencies
String[] remoteDeps = getGodotPluginsRemoteBinaries()
if (remoteDeps != null && remoteDeps.size() > 0) {
def platformPattern = /^\s*(platform|enforcedPlatform)\s*\(\s*['"]*(\S+)['"]*\s*\)$/
for (String dep : remoteDeps) {
implementation dep
def matcher = dep =~ platformPattern
if (matcher) {
switch (matcher[0][1]) {
case "platform":
implementation platform(matcher[0][2])
break
case "enforcedPlatform":
implementation enforcedPlatform(matcher[0][2])
break
default:
throw new GradleException("Invalid remote platform dependency: $dep")
break
}
} else {
implementation dep
}
}
}
@@ -205,18 +221,6 @@ android {
}
}
dev {
initWith debug
// Signing and zip-aligning are skipped for prebuilt builds, but
// performed for Godot gradle builds.
zipAlignEnabled shouldZipAlign()
if (shouldSign()) {
signingConfig signingConfigs.debug
} else {
signingConfig null
}
}
release {
// Signing and zip-aligning are skipped for prebuilt builds, but
// performed for Godot gradle builds.
@@ -250,7 +254,6 @@ android {
sourceSets {
main.res.srcDirs += ['res']
debug.jniLibs.srcDirs = ['libs/debug', 'libs/debug/vulkan_validation_layers']
dev.jniLibs.srcDirs = ['libs/dev']
release.jniLibs.srcDirs = ['libs/release']
}
@@ -329,9 +332,6 @@ module, so we're ensuring the ':app:preBuild' task is set to run after those tas
if (rootProject.tasks.findByPath("copyDebugAARToAppModule") != null) {
preBuild.mustRunAfter(rootProject.tasks.named("copyDebugAARToAppModule"))
}
if (rootProject.tasks.findByPath("copyDevAARToAppModule") != null) {
preBuild.mustRunAfter(rootProject.tasks.named("copyDevAARToAppModule"))
}
if (rootProject.tasks.findByPath("copyReleaseAARToAppModule") != null) {
preBuild.mustRunAfter(rootProject.tasks.named("copyReleaseAARToAppModule"))
}
+7 -6
View File
@@ -1,28 +1,29 @@
ext.versions = [
androidGradlePlugin: '8.6.1',
compileSdk : 35,
compileSdk : 36,
// Also update:
// - 'platform/android/export/export_plugin.cpp#DEFAULT_MIN_SDK_VERSION'
// - 'platform/android/detect.py#get_min_target_api()'
minSdk : 24,
// Also update 'platform/android/export/export_plugin.cpp#DEFAULT_TARGET_SDK_VERSION'
targetSdk : 35,
buildTools : '35.0.1',
kotlinVersion : '2.1.20',
targetSdk : 36,
buildTools : '36.1.0',
kotlinVersion : '2.1.21',
fragmentVersion : '1.8.6',
nexusPublishVersion: '1.3.0',
javaVersion : JavaVersion.VERSION_17,
// Also update 'platform/android/detect.py#get_ndk_version()' when this is updated.
ndkVersion : '28.1.13356709',
ndkVersion : '29.0.14206865',
splashscreenVersion: '1.0.1',
// 'openxrLoaderVersion' should be set to XR_CURRENT_API_VERSION, see 'thirdparty/openxr'
openxrLoaderVersion: '1.1.53',
openxrVendorsVersion: '4.2.2-stable',
openxrVendorsVersion: '4.3.0-stable',
junitVersion : '1.3.0',
espressoCoreVersion: '3.7.0',
kotlinTestVersion : '1.3.11',
testRunnerVersion : '1.7.0',
testOrchestratorVersion: '1.6.1',
documentfileVersion: '1.1.0',
]
ext.getExportPackageName = { ->
@@ -11,7 +11,8 @@
To add custom attributes, use the "gradle_build/custom_theme_attributes" Android export option. -->
<style name="GodotAppSplashTheme" parent="Theme.SplashScreen">
<item name="android:windowSplashScreenBackground">@mipmap/icon_background</item>
<item name="windowSplashScreenAnimatedIcon">@mipmap/icon_foreground</item>
<item name="android:windowSplashScreenBrandingImage">@drawable/splash_branding_image</item>
<item name="windowSplashScreenAnimatedIcon">@drawable/splash_icon</item>
<item name="postSplashScreenTheme">@style/GodotAppMainTheme</item>
<item name="android:windowIsTranslucent">false</item>
</style>
@@ -34,8 +34,11 @@ import android.content.ComponentName
import android.content.Intent
import android.util.Log
import androidx.test.core.app.ActivityScenario
import androidx.test.espresso.Espresso
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import com.godot.game.test.GodotAppInstrumentedTestPlugin
import org.godotengine.godot.Godot
import org.godotengine.godot.GodotActivity.Companion.EXTRA_COMMAND_LINE_PARAMS
import org.godotengine.godot.plugin.GodotPluginRegistry
import org.junit.Test
@@ -110,6 +113,28 @@ class GodotAppTest {
}
}
/**
* Runs test to validate android plugin signals.
*/
@Test
fun runPluginSignalTests() {
ActivityScenario.launch(GodotApp::class.java).use { scenario ->
scenario.onActivity { activity ->
val testPlugin = getTestPlugin()
assertNotNull(testPlugin)
Log.d(TAG, "Waiting for the Godot main loop to start...")
testPlugin.waitForGodotMainLoopStarted()
Log.d(TAG, "Running Android plugin signal tests...")
val result = testPlugin.runPluginSignalTests()
assertNotNull(result)
result.exceptionOrNull()?.let { throw it }
assertTrue(result.isSuccess)
}
}
}
/**
* Test implicit launch of the Godot app, and validates this resolves to the `GodotAppLauncher` activity alias.
*/
@@ -169,4 +194,56 @@ class GodotAppTest {
}
}
}
/**
* Validate that the back press does not quit the game when 'quit_on_go_back' is disabled.
*/
@Test
fun testGameNotQuittingOnBackPress() {
ActivityScenario.launch(GodotApp::class.java).use { scenario ->
val testPlugin = getTestPlugin()
assertNotNull(testPlugin)
Log.d(TAG, "Waiting for the Godot main loop to start...")
testPlugin.waitForGodotMainLoopStarted()
// Disable 'quit_on_go_back'.
testPlugin.updateQuitOnGoBack(false)
// Trigger the back press event.
Espresso.pressBackUnconditionally()
Log.d(TAG, "Waiting for the engine to terminate...")
testPlugin.waitForEngineTermination(5_000L)
val godot = Godot.getInstance(InstrumentationRegistry.getInstrumentation().targetContext)
assertTrue { godot.runStatus != Godot.RunStatus.TERMINATING }
}
}
/**
* Validate that the back press event quits the game when 'quit_on_go_back' is enabled.
*/
@Test
fun testGameQuittingOnBackPress() {
ActivityScenario.launch(GodotApp::class.java).use { scenario ->
val testPlugin = getTestPlugin()
assertNotNull(testPlugin)
Log.d(TAG, "Waiting for the Godot main loop to start...")
testPlugin.waitForGodotMainLoopStarted()
// Enable 'quit_on_go_back'.
testPlugin.updateQuitOnGoBack(true)
// Trigger the back press event.
Espresso.pressBackUnconditionally()
Log.d(TAG, "Waiting for the engine to terminate...")
testPlugin.waitForEngineTermination(5_000L)
val godot = Godot.getInstance(InstrumentationRegistry.getInstrumentation().targetContext)
assertTrue { godot.runStatus == Godot.RunStatus.TERMINATING }
}
}
}
@@ -4,6 +4,10 @@
<meta-data
android:name="org.godotengine.plugin.v2.GodotAppInstrumentedTestPlugin"
android:value="com.godot.game.test.GodotAppInstrumentedTestPlugin"/>
<meta-data
android:name="org.godotengine.plugin.v2.SignalTestPlugin"
android:value="com.godot.game.test.SignalTestPlugin"/>
</application>
</manifest>
@@ -1,4 +1,12 @@
list=[{
"base": &"BaseTest",
"class": &"AndroidPluginSignalTests",
"icon": "",
"is_abstract": false,
"is_tool": false,
"language": &"GDScript",
"path": "res://test/android_plugin/signal_tests.gd"
}, {
"base": &"RefCounted",
"class": &"BaseTest",
"icon": "",
@@ -3,12 +3,22 @@ extends Node2D
var _plugin_name = "GodotAppInstrumentedTestPlugin"
var _android_plugin
func _ready():
var _signal_test_plugin_name = "SignalTestPlugin"
var _signal_test_plugin
func _init():
# Verify plugin singleton in _init, since plugins should already be registered at this point.
if Engine.has_singleton(_plugin_name):
_android_plugin = Engine.get_singleton(_plugin_name)
_android_plugin.connect("launch_tests", _launch_tests)
else:
printerr("Couldn't find plugin " + _plugin_name)
_android_plugin.connect("update_quit_on_go_back", _update_quit_on_go_back)
if Engine.has_singleton(_signal_test_plugin_name):
_signal_test_plugin = Engine.get_singleton(_signal_test_plugin_name)
func _ready() -> void:
if not _android_plugin:
printerr("ERROR: Couldn't find plugin " + _plugin_name)
get_tree().quit()
func _launch_tests(test_label: String) -> void:
@@ -18,16 +28,22 @@ func _launch_tests(test_label: String) -> void:
test_instance = JavaClassWrapperTests.new()
"file_access_tests":
test_instance = FileAccessTests.new()
"android_plugin_signal_tests":
test_instance = AndroidPluginSignalTests.new(_signal_test_plugin)
if test_instance:
test_instance.__reset_tests()
test_instance.run_tests()
await test_instance.run_tests()
var incomplete_tests = test_instance._test_started - test_instance._test_completed
_android_plugin.onTestsCompleted(test_label, test_instance._test_completed, test_instance._test_assert_failures + incomplete_tests)
else:
_android_plugin.onTestsFailed(test_label, "Unable to launch tests")
func _update_quit_on_go_back(quit_on_go_back: bool) -> void:
get_tree().quit_on_go_back = quit_on_go_back
func _on_plugin_toast_button_pressed() -> void:
if _android_plugin:
_android_plugin.helloWorld()
@@ -1,29 +1,29 @@
[gd_scene load_steps=2 format=3 uid="uid://cg3hylang5fxn"]
[gd_scene format=3 uid="uid://cg3hylang5fxn"]
[ext_resource type="Script" uid="uid://bv6y7in6otgcm" path="res://main.gd" id="1_j0gfq"]
[node name="Main" type="Node2D"]
[node name="Main" type="Node2D" unique_id=852911723]
script = ExtResource("1_j0gfq")
[node name="VBoxContainer" type="VBoxContainer" parent="."]
[node name="VBoxContainer" type="VBoxContainer" parent="." unique_id=1839352715]
offset_left = 68.0
offset_top = 102.0
offset_right = 506.0
offset_bottom = 408.0
theme_override_constants/separation = 25
[node name="PluginToastButton" type="Button" parent="VBoxContainer"]
[node name="PluginToastButton" type="Button" parent="VBoxContainer" unique_id=1670434164]
custom_minimum_size = Vector2(0, 50)
layout_mode = 2
text = "Plugin Toast
"
[node name="VibrationButton" type="Button" parent="VBoxContainer"]
[node name="VibrationButton" type="Button" parent="VBoxContainer" unique_id=648980813]
custom_minimum_size = Vector2(0, 50)
layout_mode = 2
text = "Vibration"
[node name="GDScriptToastButton" type="Button" parent="VBoxContainer"]
[node name="GDScriptToastButton" type="Button" parent="VBoxContainer" unique_id=95554078]
custom_minimum_size = Vector2(0, 50)
layout_mode = 2
text = "GDScript Toast
@@ -8,11 +8,15 @@
config_version=5
[animation]
compatibility/default_parent_skeleton_in_mesh_instance_3d=true
[application]
config/name="Godot App Instrumentation Tests"
run/main_scene="res://main.tscn"
config/features=PackedStringArray("4.5", "GL Compatibility")
config/features=PackedStringArray("4.7", "GL Compatibility")
config/icon="res://icon.svg"
[debug]
@@ -0,0 +1,76 @@
class_name AndroidPluginSignalTests
extends BaseTest
var _plugin: JNISingleton
const emission_test_signal = "emission_test_signal"
const launch_test_signal = "launch_tests"
signal emission_test_signal_emitted
func _init(plugin: JNISingleton) -> void:
_plugin = plugin
func run_tests():
print("Android plugin signal tests starting...")
__exec_test(test_plugin_exists)
__exec_test(test_signal_registration)
__exec_test(test_signal_connection)
await __exec_test(test_signal_emission)
print("Android plugin signal tests completed.")
func test_plugin_exists() -> bool:
if _plugin == null:
printerr("ERROR: Couldn't find SignalTestPlugin plugin; _plugin is null")
return false
return true
func test_signal_registration() -> bool:
var signal_registered = _plugin.has_signal(emission_test_signal)
assert_true(signal_registered)
var launch_signal_registered = _plugin.has_signal(launch_test_signal)
assert_true(launch_signal_registered)
return true
func test_signal_connection() -> bool:
_plugin.connect(emission_test_signal, _on_emission_test_signal_emitted)
assert_equal(_plugin.has_connections(emission_test_signal), true)
_plugin.disconnect(emission_test_signal, _on_emission_test_signal_emitted)
assert_equal(_plugin.has_connections(emission_test_signal), false)
_plugin.emission_test_signal.connect(_on_emission_test_signal_emitted)
assert_equal(_plugin.has_connections(emission_test_signal), true)
_plugin.emission_test_signal.disconnect(_on_emission_test_signal_emitted)
assert_equal(_plugin.has_connections(emission_test_signal), false)
return true
func test_signal_emission() -> bool:
var err1 = _plugin.connect(emission_test_signal, _on_emission_test_signal_emitted)
assert_equal(err1, OK)
_plugin.triggerTestSignal1()
await emission_test_signal_emitted
# Test case: Same signal name, but different type and number of parameters
# The "launch_tests" signal is registered by both GodotAppInstrumentedTestPlugin and SignalTestPlugin.
# SignalTestPlugin emits it with a boolean and a string arguments, while GodotAppInstrumentedTestPlugin emits it with one string.
var err2 = _plugin.connect(launch_test_signal, _on_launch_tests_emitted)
assert_equal(err2, OK)
_plugin.triggerLaunchTestSignal()
await emission_test_signal_emitted
return true
func _on_emission_test_signal_emitted() -> void:
emission_test_signal_emitted.emit()
func _on_launch_tests_emitted(param1: bool, param2: String) -> void:
assert_true(param1)
assert_equal(param2, "second message")
emission_test_signal_emitted.emit()
@@ -0,0 +1 @@
uid://cq0mxqlbbug6r
@@ -9,8 +9,9 @@ var _test_assert_failures := 0
func __exec_test(test_func: Callable):
_test_started += 1
test_func.call()
_test_completed += 1
var ret = await test_func.call()
if ret == true:
_test_completed += 1
func __reset_tests():
_test_started = 0
@@ -9,10 +9,14 @@ func run_tests():
__exec_test(test_internal_app_dir_access)
__exec_test(test_internal_cache_dir_access)
__exec_test(test_external_app_dir_access)
__exec_test(test_downloads_dir_access)
__exec_test(test_documents_dir_access)
func _test_dir_access(dir_path: String, data_file_content: String) -> void:
# Scoped storage: Testing access to Downloads and Documents directory.
var version = JavaClassWrapper.wrap("android.os.Build$VERSION")
if version.SDK_INT >= 30:
__exec_test(test_downloads_dir_access)
__exec_test(test_documents_dir_access)
func _test_dir_access(dir_path: String, data_file_content: String) -> bool:
print("Testing access to " + dir_path)
var data_file_path = dir_path.path_join("data.dat")
@@ -29,45 +33,52 @@ func _test_dir_access(dir_path: String, data_file_content: String) -> void:
var deletion_result = DirAccess.remove_absolute(data_file_path)
assert_equal(deletion_result, OK)
return true
func test_obb_dir_access() -> void:
func test_obb_dir_access() -> bool:
var android_runtime = Engine.get_singleton("AndroidRuntime")
assert_true(android_runtime != null)
var app_context = android_runtime.getApplicationContext()
var obb_dir: String = app_context.getObbDir().getCanonicalPath()
_test_dir_access(obb_dir, FILE_CONTENT + "obb dir.")
return true
func test_internal_app_dir_access() -> void:
func test_internal_app_dir_access() -> bool:
var android_runtime = Engine.get_singleton("AndroidRuntime")
assert_true(android_runtime != null)
var app_context = android_runtime.getApplicationContext()
var internal_app_dir: String = app_context.getFilesDir().getCanonicalPath()
_test_dir_access(internal_app_dir, FILE_CONTENT + "internal app dir.")
return true
func test_internal_cache_dir_access() -> void:
func test_internal_cache_dir_access() -> bool:
var android_runtime = Engine.get_singleton("AndroidRuntime")
assert_true(android_runtime != null)
var app_context = android_runtime.getApplicationContext()
var internal_cache_dir: String = app_context.getCacheDir().getCanonicalPath()
_test_dir_access(internal_cache_dir, FILE_CONTENT + "internal cache dir.")
return true
func test_external_app_dir_access() -> void:
func test_external_app_dir_access() -> bool:
var android_runtime = Engine.get_singleton("AndroidRuntime")
assert_true(android_runtime != null)
var app_context = android_runtime.getApplicationContext()
var external_app_dir: String = app_context.getExternalFilesDir("").getCanonicalPath()
_test_dir_access(external_app_dir, FILE_CONTENT + "external app dir.")
return true
func test_downloads_dir_access() -> void:
func test_downloads_dir_access() -> bool:
var EnvironmentClass = JavaClassWrapper.wrap("android.os.Environment")
var downloads_dir = EnvironmentClass.getExternalStoragePublicDirectory(EnvironmentClass.DIRECTORY_DOWNLOADS).getCanonicalPath()
_test_dir_access(downloads_dir, FILE_CONTENT + "downloads dir.")
return true
func test_documents_dir_access() -> void:
func test_documents_dir_access() -> bool:
var EnvironmentClass = JavaClassWrapper.wrap("android.os.Environment")
var documents_dir = EnvironmentClass.getExternalStoragePublicDirectory(EnvironmentClass.DIRECTORY_DOCUMENTS).getCanonicalPath()
_test_dir_access(documents_dir, FILE_CONTENT + "documents dir.")
return true
@@ -20,12 +20,16 @@ func run_tests():
__exec_test(test_callable)
__exec_test(test_interface_callable_proxy)
__exec_test(test_interface_object_proxy)
print("JavaClassWrapper tests finished.")
print("Tests started: " + str(_test_started))
print("Tests completed: " + str(_test_completed))
func test_exceptions() -> void:
func test_exceptions() -> bool:
var TestClass: JavaClass = JavaClassWrapper.wrap('com.godot.game.test.javaclasswrapper.TestClass')
#print(TestClass.get_java_method_list())
@@ -36,7 +40,9 @@ func test_exceptions() -> void:
assert_equal(JavaClassWrapper.get_exception(), null)
func test_multiple_signatures() -> void:
return true
func test_multiple_signatures() -> bool:
var TestClass: JavaClass = JavaClassWrapper.wrap('com.godot.game.test.javaclasswrapper.TestClass')
var ai := [1, 2]
@@ -55,7 +61,9 @@ func test_multiple_signatures() -> void:
]
assert_equal(TestClass.testMethod(3, aobjl), "testObjects: 27 135")
func test_array_arguments() -> void:
return true
func test_array_arguments() -> bool:
var TestClass: JavaClass = JavaClassWrapper.wrap('com.godot.game.test.javaclasswrapper.TestClass')
assert_equal(TestClass.testArgBoolArray([true, false, true]), "[true, false, true]")
@@ -72,7 +80,9 @@ func test_array_arguments() -> void:
assert_equal(TestClass.testArgDoubleArray(PackedFloat64Array([37.1, 38.2, 39.3])), "[37.1, 38.2, 39.3]")
assert_equal(TestClass.testArgDoubleArray([37.1, 38.2, 39.3]), "[37.1, 38.2, 39.3]")
func test_array_return() -> void:
return true
func test_array_return() -> bool:
var TestClass: JavaClass = JavaClassWrapper.wrap('com.godot.game.test.javaclasswrapper.TestClass')
#print(TestClass.get_java_method_list())
@@ -107,14 +117,17 @@ func test_array_return() -> void:
assert_equal(TestClass.testRetStringArray(), PackedStringArray(["I", "am", "String"]))
assert_equal(TestClass.testRetCharSequenceArray(), PackedStringArray(["I", "am", "CharSequence"]))
func test_dictionary():
return true
func test_dictionary() -> bool:
var TestClass: JavaClass = JavaClassWrapper.wrap('com.godot.game.test.javaclasswrapper.TestClass')
assert_equal(TestClass.testDictionary({a = 1, b = 2}), "{a=1, b=2}")
assert_equal(TestClass.testRetDictionary(), {a = 1, b = 2})
assert_equal(TestClass.testRetDictionaryArray(), [{a = 1, b = 2}])
assert_equal(TestClass.testDictionaryNested({a = 1, b = [2, 3], c = 4}), "{a: 1, b: [2, 3], c: 4}")
return true
func test_object_overload():
func test_object_overload() -> bool:
var TestClass: JavaClass = JavaClassWrapper.wrap('com.godot.game.test.javaclasswrapper.TestClass')
var TestClass2: JavaClass = JavaClassWrapper.wrap('com.godot.game.test.javaclasswrapper.TestClass2')
var TestClass3: JavaClass = JavaClassWrapper.wrap('com.godot.game.test.javaclasswrapper.TestClass3')
@@ -130,22 +143,25 @@ func test_object_overload():
assert_equal(TestClass.testObjectOverloadArray(arr_of_t2), "TestClass2: [33, 34]")
assert_equal(TestClass.testObjectOverloadArray(arr_of_t3), "TestClass3: [thirty three, thirty four]")
return true
func test_variant_conversion_safe_from_stack_overflow():
func test_variant_conversion_safe_from_stack_overflow() -> bool:
var TestClass: JavaClass = JavaClassWrapper.wrap('com.godot.game.test.javaclasswrapper.TestClass')
var arr: Array = [42]
var dict: Dictionary = {"arr": arr}
arr.append(dict)
# The following line will crash with stack overflow if not handled property:
TestClass.testDictionary(dict)
return true
func test_big_integers():
func test_big_integers() -> bool:
var TestClass: JavaClass = JavaClassWrapper.wrap('com.godot.game.test.javaclasswrapper.TestClass')
assert_equal(TestClass.testArgLong(4242424242), "4242424242")
assert_equal(TestClass.testArgLong(-4242424242), "-4242424242")
assert_equal(TestClass.testDictionary({a = 4242424242, b = -4242424242}), "{a=4242424242, b=-4242424242}")
return true
func test_callable():
func test_callable() -> bool:
var android_runtime = Engine.get_singleton("AndroidRuntime")
assert_true(android_runtime != null)
@@ -155,3 +171,36 @@ func test_callable():
return null
android_runtime.createRunnableFromGodotCallable(cb1).run()
assert_equal(cb1_data['called'], true)
return true
func test_interface_callable_proxy() -> bool:
var cb1_data := {called = false, content = ""}
var cb1 = func (content: String) -> void:
cb1_data['called'] = true
cb1_data['content'] = content
var printer_proxy = JavaClassWrapper.create_sam_callback("android.util.Printer", cb1)
assert_true(printer_proxy != null)
printer_proxy.println("This is a callback test")
assert_equal(cb1_data['called'], true)
assert_equal(cb1_data['content'], "This is a callback test")
return true
class PrintProxy:
var test_data := {called = false, content = ""}
func println(content: String) -> void:
test_data['called'] = true
test_data['content'] = content
func test_interface_object_proxy() -> bool:
var print_object = PrintProxy.new()
var proxy = JavaClassWrapper.create_proxy(print_object, ["android.util.Printer"])
assert_true(proxy != null)
proxy.println("This is proxy test")
assert_equal(print_object.test_data['called'], true)
assert_equal(print_object.test_data['content'], "This is proxy test")
return true
@@ -38,6 +38,7 @@ import org.godotengine.godot.plugin.UsedByGodot
import org.godotengine.godot.plugin.SignalInfo
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
/**
* [GodotPlugin] used to drive instrumented tests.
@@ -47,14 +48,18 @@ class GodotAppInstrumentedTestPlugin(godot: Godot) : GodotPlugin(godot) {
companion object {
private val TAG = GodotAppInstrumentedTestPlugin::class.java.simpleName
private const val MAIN_LOOP_STARTED_LATCH_KEY = "main_loop_started_latch"
private const val ENGINE_TERMINATING_LATCH_KEY = "engine_terminating_latch"
private const val JAVACLASSWRAPPER_TESTS = "javaclasswrapper_tests"
private const val FILE_ACCESS_TESTS = "file_access_tests"
private const val PLUGIN_SIGNAL_TESTS = "android_plugin_signal_tests"
private val LAUNCH_TESTS_SIGNAL = SignalInfo("launch_tests", String::class.java)
private val UPDATE_QUIT_ON_GO_BACK_SIGNAL = SignalInfo("update_quit_on_go_back", java.lang.Boolean::class.java)
private val SIGNALS = setOf(
LAUNCH_TESTS_SIGNAL
LAUNCH_TESTS_SIGNAL,
UPDATE_QUIT_ON_GO_BACK_SIGNAL
)
}
@@ -65,6 +70,8 @@ class GodotAppInstrumentedTestPlugin(godot: Godot) : GodotPlugin(godot) {
// Add a countdown latch that is triggered when `onGodotMainLoopStarted` is fired.
// This will be used by tests to wait until the engine is ready.
latches[MAIN_LOOP_STARTED_LATCH_KEY] = CountDownLatch(1)
// Add a countdown latch that is triggered when the engine terminates.
latches[ENGINE_TERMINATING_LATCH_KEY] = CountDownLatch(1)
}
override fun getPluginName() = "GodotAppInstrumentedTestPlugin"
@@ -76,6 +83,11 @@ class GodotAppInstrumentedTestPlugin(godot: Godot) : GodotPlugin(godot) {
latches.remove(MAIN_LOOP_STARTED_LATCH_KEY)?.countDown()
}
override fun onGodotTerminating() {
super.onGodotTerminating()
latches.remove(ENGINE_TERMINATING_LATCH_KEY)?.countDown()
}
/**
* Used by the instrumented test to wait until the Godot main loop is up and running.
*/
@@ -88,6 +100,19 @@ class GodotAppInstrumentedTestPlugin(godot: Godot) : GodotPlugin(godot) {
}
}
internal fun waitForEngineTermination(timeoutInMs: Long) {
// Wait on the CountDownLatch for `onGodotTerminating`.
try {
latches[ENGINE_TERMINATING_LATCH_KEY]?.await(timeoutInMs, TimeUnit.MILLISECONDS)
} catch (e: InterruptedException) {
Log.e(TAG, "Unable to wait for engine termination event.", e)
}
}
internal fun updateQuitOnGoBack(quitOnGoBack: Boolean) {
emitSignal(UPDATE_QUIT_ON_GO_BACK_SIGNAL, quitOnGoBack)
}
/**
* This launches the JavaClassWrapper tests, and wait until the tests are complete before returning.
*/
@@ -102,9 +127,13 @@ class GodotAppInstrumentedTestPlugin(godot: Godot) : GodotPlugin(godot) {
return launchTests(FILE_ACCESS_TESTS)
}
internal fun runPluginSignalTests(): Result<Any>? {
return launchTests(PLUGIN_SIGNAL_TESTS)
}
private fun launchTests(testLabel: String): Result<Any>? {
val latch = latches.getOrPut(testLabel) { CountDownLatch(1) }
emitSignal(LAUNCH_TESTS_SIGNAL.name, testLabel)
emitSignal(LAUNCH_TESTS_SIGNAL, testLabel)
return try {
latch.await()
val result = testResults.remove(testLabel)
@@ -0,0 +1,35 @@
package com.godot.game.test
import android.util.Log
import org.godotengine.godot.Dictionary
import org.godotengine.godot.Godot
import org.godotengine.godot.plugin.GodotPlugin
import org.godotengine.godot.plugin.SignalInfo
import org.godotengine.godot.plugin.UsedByGodot
class SignalTestPlugin(godot: Godot) : GodotPlugin(godot) {
companion object {
private val EMISSION_TEST_SIGNAL = SignalInfo("emission_test_signal")
private val LAUNCH_TESTS_SIGNAL = SignalInfo("launch_tests", java.lang.Boolean::class.java, String::class.java)
}
override fun getPluginName() = "SignalTestPlugin"
override fun getPluginSignals(): Set<SignalInfo?> {
return setOf(
EMISSION_TEST_SIGNAL,
LAUNCH_TESTS_SIGNAL
)
}
@UsedByGodot
fun triggerTestSignal1() {
emitSignal(EMISSION_TEST_SIGNAL)
}
@UsedByGodot
fun triggerLaunchTestSignal() {
emitSignal(LAUNCH_TESTS_SIGNAL, true, "second message")
}
}
@@ -35,6 +35,7 @@
android:launchMode="singleInstancePerTask"
android:excludeFromRecents="false"
android:exported="false"
android:supportsPictureInPicture="true"
android:screenOrientation="landscape"
android:windowSoftInputMode="adjustResize"
android:configChanges="layoutDirection|locale|orientation|keyboardHidden|screenSize|smallestScreenSize|density|keyboard|navigation|screenLayout|uiMode"
@@ -67,9 +67,14 @@ public class GodotApp extends GodotActivity {
@Override
public void onCreate(Bundle savedInstanceState) {
SplashScreen.installSplashScreen(this);
SplashScreen splashScreen = SplashScreen.installSplashScreen(this);
EdgeToEdge.enable(this);
super.onCreate(savedInstanceState);
Godot godot = getGodot();
if (godot != null && godot.getDisableGodotSplash()) {
splashScreen.setKeepOnScreenCondition(() -> godot.getRunStatus() != Godot.RunStatus.STARTED);
}
}
@Override
@@ -92,4 +97,9 @@ public class GodotApp extends GodotActivity {
super.onGodotForceQuit(instance);
}
}
@Override
protected boolean isPiPEnabled() {
return true;
}
}
+4 -13
View File
@@ -27,8 +27,8 @@ ext {
supportedFlavors = ["editor", "template"]
supportedAndroidDistributions = ["android", "horizonos", "picoos"]
supportedFlavorsBuildTypes = [
"editor": ["dev", "debug", "release"],
"template": ["dev", "debug", "release"]
"editor": ["debug", "release"],
"template": ["debug", "release"]
]
supportedEditions = ["standard", "mono"]
@@ -50,11 +50,11 @@ def getSconsTaskName(String flavor, String buildType, String abi) {
/**
* Generate Godot gradle build template by zipping the source files from the app directory, as well
* as the AAR files generated by 'copyDebugAAR', 'copyDevAAR' and 'copyReleaseAAR'.
* as the AAR files generated by 'copyDebugAAR' and 'copyReleaseAAR'.
* The zip file also includes some gradle tools to enable gradle builds from the Godot Editor.
*/
task zipGradleBuild(type: Zip) {
onlyIf { generateGodotTemplates.state.executed || generateGodotMonoTemplates.state.executed || generateDevTemplate.state.executed }
onlyIf { generateGodotTemplates.state.executed || generateGodotMonoTemplates.state.executed }
doFirst {
logger.lifecycle("Generating Godot gradle build template")
}
@@ -124,9 +124,6 @@ def generateBuildTasks(String flavor = "template", String edition = "standard",
File targetLibs = new File(libsDir + target)
String targetSuffix = target
if (target == "dev") {
targetSuffix = "debug.dev"
}
if (!excludeSconsBuildTasks || (targetLibs != null
&& targetLibs.isDirectory()
@@ -315,26 +312,20 @@ task cleanGodotTemplates(type: Delete) {
// Delete the Godot templates in the Godot bin directory
delete("$binDir/android_debug.apk")
delete("$binDir/android_dev.apk")
delete("$binDir/android_release.apk")
delete("$binDir/android_monoDebug.apk")
delete("$binDir/android_monoDev.apk")
delete("$binDir/android_monoRelease.apk")
delete("$binDir/android_source.zip")
delete("$binDir/godot-lib.template_debug.aar")
delete("$binDir/godot-lib.template_debug.dev.aar")
delete("$binDir/godot-lib.template_release.aar")
// Cover deletion for the libs using the previous naming scheme
delete("$binDir/godot-lib.debug.aar")
delete("$binDir/godot-lib.dev.aar")
delete("$binDir/godot-lib.release.aar")
// Delete the native debug symbols files.
delete("$binDir/android-editor-debug-native-symbols.zip")
delete("$binDir/android-editor-dev-native-symbols.zip")
delete("$binDir/android-editor-release-native-symbols.zip")
delete("$binDir/android-template-debug-native-symbols.zip")
delete("$binDir/android-template-dev-native-symbols.zip")
delete("$binDir/android-template-release-native-symbols.zip")
}
+8 -7
View File
@@ -131,14 +131,7 @@ android {
}
buildTypes {
dev {
initWith debug
applicationIdSuffix ".dev"
manifestPlaceholders += [editorBuildSuffix: " (dev)"]
}
debug {
initWith release
applicationIdSuffix ".debug"
manifestPlaceholders += [editorBuildSuffix: " (debug)"]
signingConfig signingConfigs.debug
@@ -147,6 +140,14 @@ android {
release {
if (hasReleaseSigningConfigs()) {
signingConfig signingConfigs.release
} else {
// We default to the debug signingConfigs when the release signing configs are not
// available (e.g: development in Android Studio).
signingConfig signingConfigs.debug
// In addition, we update the application ID to allow installing an Android studio release build
// side by side with a production build from the store.
applicationIdSuffix ".release"
manifestPlaceholders += [editorBuildSuffix: " (release)"]
}
}
}
@@ -16,6 +16,14 @@
<uses-feature
android:glEsVersion="0x00030000"
android:required="true" />
<uses-feature
android:name="android.hardware.vulkan.version"
android:required="false"
android:version="0x401000" />
<uses-feature
android:name="android.hardware.vulkan.level"
android:required="false"
android:version="1" />
<uses-feature
android:name="android.hardware.camera"
android:required="false" />
@@ -52,7 +60,7 @@
android:configChanges="layoutDirection|locale|orientation|keyboardHidden|screenSize|smallestScreenSize|density|keyboard|navigation|screenLayout|uiMode"
android:exported="false"
android:icon="@mipmap/themed_icon"
android:launchMode="singleTask"
android:launchMode="singleInstancePerTask"
android:screenOrientation="userLandscape">
<layout
android:defaultWidth="@dimen/editor_default_window_width"
@@ -85,6 +93,15 @@
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<data android:scheme="file" />
<data android:scheme="content" />
<data android:mimeType="*/*" />
<data android:host="*" />
<data android:pathPattern=".*\\.godot" />
</intent-filter>
</activity-alias>
<activity
android:name=".GodotGame"
@@ -94,6 +111,7 @@
android:label="@string/godot_game_activity_name"
android:launchMode="singleTask"
android:process=":GodotGame"
android:taskAffinity=":game"
android:autoRemoveFromRecents="true"
android:theme="@style/GodotGameTheme"
android:supportsPictureInPicture="true"
@@ -34,9 +34,12 @@ import android.Manifest
import android.app.ActivityManager
import android.app.ActivityOptions
import android.content.ComponentName
import android.content.ContentResolver
import android.content.Context
import android.content.Intent
import android.content.pm.ActivityInfo
import android.content.pm.PackageManager
import android.content.res.Configuration
import android.os.Build
import android.os.Bundle
import android.os.Debug
@@ -71,7 +74,9 @@ import org.godotengine.godot.utils.DialogUtils
import org.godotengine.godot.utils.PermissionsUtil
import org.godotengine.godot.utils.ProcessPhoenix
import org.godotengine.openxr.vendors.utils.*
import java.io.File
import kotlin.math.min
import kotlin.text.indexOf
/**
* Base class for the Godot Android Editor activities.
@@ -152,6 +157,7 @@ abstract class BaseGodotEditor : GodotActivity(), GameMenuFragment.GameMenuListe
internal const val GAME_MENU_ACTION_SET_TIME_SCALE = "setTimeScale"
private const val GAME_WORKSPACE = "Game"
private const val SCRIPT_WORKSPACE = "Script"
internal const val SNACKBAR_SHOW_DURATION_MS = 5000L
@@ -198,6 +204,12 @@ abstract class BaseGodotEditor : GodotActivity(), GameMenuFragment.GameMenuListe
protected var gameMenuFragment: GameMenuFragment? = null
protected val gameMenuState = Bundle()
private val updatedCommandLineParams = ArrayList<String>()
private var changingOrientationAllowed = false
private var distractionFreeModeEnabled = false
private var activeWorkspace: String? = null
override fun getGodotAppLayout() = R.layout.godot_editor_layout
internal open fun getEditorWindowInfo() = EDITOR_MAIN_INFO
@@ -254,7 +266,7 @@ abstract class BaseGodotEditor : GodotActivity(), GameMenuFragment.GameMenuListe
editorMessageDispatcher.parseStartIntent(packageManager, intent)
if (BuildConfig.BUILD_TYPE == "dev" && WAIT_FOR_DEBUGGER) {
if (BuildConfig.BUILD_TYPE == "debug" && WAIT_FOR_DEBUGGER) {
Debug.waitForDebugger()
}
@@ -264,6 +276,14 @@ abstract class BaseGodotEditor : GodotActivity(), GameMenuFragment.GameMenuListe
setupGameMenuBar()
}
override fun onConfigurationChanged(newConfig: Configuration) {
super.onConfigurationChanged(newConfig)
// Show EditorTitleBar on small screens only in landscape due to width limitations in portrait.
// TODO: Enable for portrait once the title bar width is optimized.
EditorUtils.toggleTitleBar(isLargeScreen || newConfig.orientation == Configuration.ORIENTATION_LANDSCAPE)
}
override fun onDestroy() {
gradleBuildProvider.buildEnvDisconnect()
super.onDestroy()
@@ -319,6 +339,91 @@ abstract class BaseGodotEditor : GodotActivity(), GameMenuFragment.GameMenuListe
super.onNewIntent(newIntent)
}
override fun handleStartIntent(intent: Intent, newLaunch: Boolean) {
when (intent.action) {
Intent.ACTION_VIEW -> {
val rootDir = Environment.getExternalStorageDirectory().canonicalPath
val dataPath = when (intent.scheme) {
ContentResolver.SCHEME_FILE -> {
intent.data?.path
}
ContentResolver.SCHEME_CONTENT -> {
// This approach is not recommend with 'content' scheme, but we require the filesystem path in
// order to open its parent directory and load the project.
val uriPath = intent.data?.path
if (uriPath != null) {
// Try and see if the external storage directory is part of the uri path.
val rootDirIndex = uriPath.indexOf(rootDir)
if (rootDirIndex != -1) {
uriPath.substring(rootDirIndex)
} else {
// Try and see if we can retrieve an existing relative path.
val pathParts = uriPath.split(':', '/')
var currentPath = ""
for (index in pathParts.size -1 downTo 0) {
currentPath = if (currentPath == "") {
pathParts[index]
} else {
"${pathParts[index]}/$currentPath"
}
val currentFile = File(rootDir, currentPath)
if (currentFile.exists()) {
break
}
}
currentPath
}
} else {
null
}
}
else -> null
}
if (!dataPath.isNullOrBlank()) {
var dataFile = File(dataPath)
if (!dataFile.isAbsolute) {
dataFile = File(rootDir, dataPath)
}
val dataDir = dataFile.parentFile
if (dataDir?.isDirectory == true) {
val loadProjectArgs = arrayOf(EDITOR_ARG, PATH_ARG, dataDir.absolutePath)
if (newLaunch) {
// Update the command line parameters to load the specified project.
updatedCommandLineParams.addAll(loadProjectArgs)
} else {
// Check if we are already editing the specified directory.
var isEditor = false
var nextIsPath = false
var currentPath = ""
for (arg in commandLine) {
if (nextIsPath) {
currentPath = arg
nextIsPath = false
}
if (arg == EDITOR_ARG || arg == EDITOR_ARG_SHORT) {
isEditor = true
} else if (arg == PATH_ARG) {
nextIsPath = true
}
}
if (!isEditor || currentPath != dataDir.absolutePath) {
onNewGodotInstanceRequested(loadProjectArgs)
}
}
}
}
}
}
super.handleStartIntent(intent, newLaunch)
}
protected open fun shouldShowGameMenuBar() = gameMenuContainer != null
private fun setupGameMenuBar() {
@@ -345,6 +450,7 @@ abstract class BaseGodotEditor : GodotActivity(), GameMenuFragment.GameMenuListe
val longPressEnabled = enableLongPressGestures()
val panScaleEnabled = enablePanAndScaleGestures()
val overrideVolumeButtonsEnabled = overrideVolumeButtons()
val hapticEnabled = enableHapticOnLongPress()
runOnUiThread {
// Enable long press, panning and scaling gestures
@@ -352,6 +458,7 @@ abstract class BaseGodotEditor : GodotActivity(), GameMenuFragment.GameMenuListe
enableLongPress(longPressEnabled)
enablePanningAndScalingGestures(panScaleEnabled)
setOverrideVolumeButtons(overrideVolumeButtonsEnabled)
enableHapticFeedback(hapticEnabled)
}
}
}
@@ -404,7 +511,10 @@ abstract class BaseGodotEditor : GodotActivity(), GameMenuFragment.GameMenuListe
override fun getCommandLine(): MutableList<String> {
val params = super.getCommandLine()
if (BuildConfig.BUILD_TYPE == "dev" && !params.contains("--benchmark")) {
if (updatedCommandLineParams.isNotEmpty()) {
params.addAll(updatedCommandLineParams)
}
if (BuildConfig.BUILD_TYPE == "debug" && !params.contains("--benchmark")) {
params.add("--benchmark")
}
return params
@@ -603,7 +713,7 @@ abstract class BaseGodotEditor : GodotActivity(), GameMenuFragment.GameMenuListe
/**
* The Godot Android Editor sets its own orientation via its AndroidManifest
*/
protected open fun overrideOrientationRequest() = true
protected open fun overrideOrientationRequest() = !changingOrientationAllowed
protected open fun overrideVolumeButtons() = false
@@ -613,6 +723,12 @@ abstract class BaseGodotEditor : GodotActivity(), GameMenuFragment.GameMenuListe
protected open fun enableLongPressGestures() =
java.lang.Boolean.parseBoolean(GodotLib.getEditorSetting("interface/touchscreen/enable_long_press_as_right_click"))
/**
* Enable haptic feedback on long-press right-click for the Godot Android editor.
*/
protected open fun enableHapticOnLongPress() =
java.lang.Boolean.parseBoolean(GodotLib.getEditorSetting("interface/touchscreen/haptic_on_long_press"))
/**
* Disable scroll deadzone for the Godot Android editor.
*/
@@ -801,6 +917,8 @@ abstract class BaseGodotEditor : GodotActivity(), GameMenuFragment.GameMenuListe
}
override fun onEditorWorkspaceSelected(workspace: String) {
activeWorkspace = workspace
if (workspace == GAME_WORKSPACE && shouldShowGameMenuBar()) {
if (editorMessageDispatcher.bringEditorWindowToFront(EMBEDDED_RUN_GAME_INFO) || editorMessageDispatcher.bringEditorWindowToFront(RUN_GAME_INFO)) {
return
@@ -813,6 +931,23 @@ abstract class BaseGodotEditor : GodotActivity(), GameMenuFragment.GameMenuListe
embeddedGameViewContainerWindow?.isVisible = true
}
}
toggleScriptEditorOrientation()
}
override fun onDistractionFreeModeChanged(enabled: Boolean) {
distractionFreeModeEnabled = enabled
toggleScriptEditorOrientation()
}
private fun toggleScriptEditorOrientation() {
if (activeWorkspace == SCRIPT_WORKSPACE && distractionFreeModeEnabled) {
changingOrientationAllowed = true
requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_USER
} else if (changingOrientationAllowed) {
requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_USER_LANDSCAPE
changingOrientationAllowed = false
}
}
internal open fun bringSelfToFront() {
@@ -30,12 +30,7 @@
package org.godotengine.editor
import android.app.PictureInPictureParams
import android.content.pm.PackageManager
import android.graphics.Rect
import android.os.Build
import android.os.Bundle
import android.util.Log
import android.view.View
import androidx.annotation.CallSuper
import androidx.core.view.isVisible
@@ -55,7 +50,6 @@ open class GodotGame : BaseGodotGame() {
private val TAG = GodotGame::class.java.simpleName
}
private val gameViewSourceRectHint = Rect()
private val expandGameMenuButton: View? by lazy { findViewById(R.id.game_menu_expand_button) }
override fun onCreate(savedInstanceState: Bundle?) {
@@ -75,13 +69,6 @@ open class GodotGame : BaseGodotGame() {
gameMenuFragment?.expandGameMenu()
}
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val gameView = findViewById<View>(R.id.godot_fragment_container)
gameView?.addOnLayoutChangeListener { v, left, top, right, bottom, oldLeft, oldTop, oldRight, oldBottom ->
gameView.getGlobalVisibleRect(gameViewSourceRectHint)
}
}
}
override fun getCommandLine(): MutableList<String> {
@@ -96,27 +83,7 @@ open class GodotGame : BaseGodotGame() {
return updatedArgs
}
override fun enterPiPMode() {
if (hasPiPSystemFeature()) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val builder = PictureInPictureParams.Builder().setSourceRectHint(gameViewSourceRectHint)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
builder.setSeamlessResizeEnabled(false)
}
setPictureInPictureParams(builder.build())
}
Log.v(TAG, "Entering PiP mode")
enterPictureInPictureMode()
}
}
/**
* Returns true the if the device supports picture-in-picture (PiP).
*/
protected fun hasPiPSystemFeature(): Boolean {
return packageManager.hasSystemFeature(PackageManager.FEATURE_PICTURE_IN_PICTURE)
}
override fun isPiPEnabled() = true
override fun shouldShowGameMenuBar(): Boolean {
return intent.getBooleanExtra(
@@ -127,21 +94,11 @@ open class GodotGame : BaseGodotGame() {
override fun onPictureInPictureModeChanged(isInPictureInPictureMode: Boolean) {
super.onPictureInPictureModeChanged(isInPictureInPictureMode)
Log.v(TAG, "onPictureInPictureModeChanged: $isInPictureInPictureMode")
// Hide the game menu fragment when in PiP.
gameMenuContainer?.isVisible = !isInPictureInPictureMode
}
override fun onStop() {
super.onStop()
if (isInPictureInPictureMode && !isFinishing) {
// We get in this state when PiP is closed, so we terminate the activity.
finish()
}
}
override fun getGodotAppLayout() = R.layout.godot_game_layout
override fun getEditorWindowInfo() = RUN_GAME_INFO
@@ -258,7 +215,7 @@ open class GodotGame : BaseGodotGame() {
override fun isCloseButtonEnabled() = !isHorizonOSDevice(applicationContext)
override fun isPiPButtonEnabled() = hasPiPSystemFeature()
override fun isPiPButtonEnabled() = isPiPModeSupported()
override fun isMenuBarCollapsable() = true
@@ -31,13 +31,18 @@
package org.godotengine.editor.embed
import android.content.pm.ActivityInfo
import android.graphics.Color
import android.graphics.Point
import android.os.Bundle
import android.util.Rational
import android.view.Gravity
import android.view.MotionEvent
import android.view.View
import android.view.WindowManager
import android.view.WindowManager.LayoutParams.FLAG_DIM_BEHIND
import android.view.WindowManager.LayoutParams.FLAG_NOT_TOUCH_MODAL
import android.view.WindowManager.LayoutParams.FLAG_WATCH_OUTSIDE_TOUCH
import android.widget.CheckBox
import org.godotengine.editor.GodotGame
import org.godotengine.editor.R
import org.godotengine.godot.editor.utils.GameMenuUtils
@@ -50,22 +55,67 @@ class EmbeddedGodotGame : GodotGame() {
companion object {
private val TAG = EmbeddedGodotGame::class.java.simpleName
private const val FULL_SCREEN_WIDTH = WindowManager.LayoutParams.MATCH_PARENT
private const val FULL_SCREEN_HEIGHT = WindowManager.LayoutParams.MATCH_PARENT
private const val PREFS_NAME = "embedded_game_window_prefs"
private const val KEY_X = "embedded_window_x"
private const val KEY_Y = "embedded_window_y"
private const val KEY_WIDTH = "embedded_window_width"
private const val KEY_HEIGHT = "embedded_window_height"
private const val KEY_FREE_RESIZE = "is_free_resize"
private const val RESIZE_THRESHOLD = 80f
private const val MIN_WINDOW_SIZE = 400
private const val MAX_SCREEN_PERCENT = 0.9f
private const val RESIZE_UI_HIDE_DELAY_MS = 2000L
}
private val defaultWidthInPx : Int by lazy {
resources.getDimensionPixelSize(R.dimen.embed_game_window_default_width)
}
private val defaultHeightInPx : Int by lazy {
resources.getDimensionPixelSize(R.dimen.embed_game_window_default_height)
}
private val defaultWidthInPx: Int by lazy { resources.getDimensionPixelSize(R.dimen.embed_game_window_default_width) }
private val defaultHeightInPx: Int by lazy { resources.getDimensionPixelSize(R.dimen.embed_game_window_default_height) }
private var layoutWidthInPx = 0
private var layoutHeightInPx = 0
private var gameRequestedOrientation = ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED
private var isFullscreen = false
private var gameRequestedOrientation = ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED
private var resizingEnabled = false
private var isResizing = false
private var activeCorner = 0
private var isFreeResize = false
private var initialWinX = 0
private var initialWinY = 0
private var initialWidth = 0
private var initialHeight = 0
private var initialTouchX = 0f
private var initialTouchY = 0f
private val lockAspectRatioCheckBox: CheckBox by lazy { findViewById(R.id.lockAspectRatioCheckBox) }
private val cornerHandles = mutableListOf<View>()
private val screenBounds: android.graphics.Rect by lazy {
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.R) {
windowManager.currentWindowMetrics.bounds
} else {
val size = Point()
windowManager.defaultDisplay.getRealSize(size)
android.graphics.Rect(0, 0, size.x, size.y)
}
}
private val maxAllowedWidth: Int get() = (screenBounds.width() * MAX_SCREEN_PERCENT).toInt()
private val maxAllowedHeight: Int get() = (screenBounds.height() * MAX_SCREEN_PERCENT).toInt()
private var lockedAspectRatio: Float = 1.6f
private val disableResizeHandler = android.os.Handler(android.os.Looper.getMainLooper())
private val disableResizeRunnable = Runnable {
if (isResizing) return@Runnable // Keep it active user is resizing again
resizingEnabled = false
gameMenuFragment?.toggleDragButton(false)
lockAspectRatioCheckBox.visibility = View.GONE
cornerHandles.forEach { it.visibility = View.GONE }
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
@@ -75,14 +125,54 @@ class EmbeddedGodotGame : GodotGame() {
val layoutParams = window.attributes
layoutParams.flags = layoutParams.flags or FLAG_NOT_TOUCH_MODAL or FLAG_WATCH_OUTSIDE_TOUCH
layoutParams.flags = layoutParams.flags and FLAG_DIM_BEHIND.inv()
layoutParams.gravity = Gravity.END or Gravity.BOTTOM
layoutParams.gravity = Gravity.TOP or Gravity.START
layoutWidthInPx = defaultWidthInPx
layoutHeightInPx = defaultHeightInPx
layoutParams.width = layoutWidthInPx
layoutParams.height = layoutHeightInPx
loadWindowBounds(layoutParams)
window.attributes = layoutParams
setupOverlayUI()
}
override fun getGodotAppLayout() = R.layout.godot_embedded_game_layout
private fun setupOverlayUI() {
lockAspectRatioCheckBox.isChecked = !isFreeResize
lockAspectRatioCheckBox.setOnCheckedChangeListener { _, isChecked ->
isFreeResize = !isChecked
hideResizeUI()
if (isChecked) {
val lp = window.attributes
lockedAspectRatio = lp.width.toFloat() / lp.height.toFloat().coerceAtLeast(1f)
}
saveWindowBounds(updatePiPParams = false)
}
cornerHandles.apply {
clear()
add(findViewById(R.id.handleTopLeft))
add(findViewById(R.id.handleTopRight))
add(findViewById(R.id.handleBottomLeft))
add(findViewById(R.id.handleBottomRight))
}
}
private fun updateLabelText(w: Int, h: Int) {
lockAspectRatioCheckBox.text = getString(R.string.lock_aspect_ratio_btn_text, w, h)
}
private fun showResizeUI() {
disableResizeHandler.removeCallbacks(disableResizeRunnable)
if (!resizingEnabled) {
resizingEnabled = true
lockAspectRatioCheckBox.visibility = View.VISIBLE
cornerHandles.forEach { it.visibility = View.VISIBLE }
}
}
private fun hideResizeUI() {
disableResizeHandler.removeCallbacks(disableResizeRunnable)
disableResizeHandler.postDelayed(disableResizeRunnable, RESIZE_UI_HIDE_DELAY_MS)
}
override fun setRequestedOrientation(requestedOrientation: Int) {
@@ -97,40 +187,222 @@ class EmbeddedGodotGame : GodotGame() {
}
override fun dispatchTouchEvent(event: MotionEvent): Boolean {
if (isFullscreen) return super.dispatchTouchEvent(event)
val layoutParams = window.attributes
when (event.actionMasked) {
MotionEvent.ACTION_OUTSIDE -> {
if (!isFullscreen) {
if (gameMenuFragment?.isAlwaysOnTop() == true) {
enterPiPMode()
} else {
minimizeGameWindow()
if (gameMenuFragment?.isAlwaysOnTop() == true) {
updatePiPParams(aspectRatio = Rational(layoutWidthInPx, layoutHeightInPx))
enterPiPMode()
} else {
minimizeGameWindow()
}
}
MotionEvent.ACTION_DOWN -> {
if (resizingEnabled) {
// Check if the click is inside the label's bounds
val location = IntArray(2)
lockAspectRatioCheckBox.getLocationOnScreen(location)
val checkBoxRect = android.graphics.Rect(
location[0], location[1],
location[0] + lockAspectRatioCheckBox.width,
location[1] + lockAspectRatioCheckBox.height
)
if (checkBoxRect.contains(event.rawX.toInt(), event.rawY.toInt())) {
// Let the CheckBox handle the click itself
return super.dispatchTouchEvent(event)
}
activeCorner = getTouchedCorner(event.x, event.y, layoutParams.width, layoutParams.height)
if (activeCorner != 0) {
isResizing = true
initialTouchX = event.rawX
initialTouchY = event.rawY
initialWinX = layoutParams.x
initialWinY = layoutParams.y
initialWidth = layoutParams.width
initialHeight = layoutParams.height
return true
}
}
}
MotionEvent.ACTION_MOVE -> {
// val layoutParams = window.attributes
// TODO: Add logic to move the embedded window.
// window.attributes = layoutParams
if (resizingEnabled && isResizing) {
val dx = (event.rawX - initialTouchX).toInt()
val dy = (event.rawY - initialTouchY).toInt()
applyResizeLogic(layoutParams, dx, dy)
updateLabelText(layoutParams.width, layoutParams.height)
window.attributes = layoutParams
return true
}
}
MotionEvent.ACTION_UP -> {
if (isResizing) {
isResizing = false
saveWindowBounds()
hideResizeUI()
return true
}
}
}
return super.dispatchTouchEvent(event)
}
private fun applyResizeLogic(layoutParams: WindowManager.LayoutParams, dx: Int, dy: Int) {
var newW = initialWidth
var newH = initialHeight
when (activeCorner) {
Gravity.TOP or Gravity.START -> {
newW = (initialWidth - dx).coerceIn(MIN_WINDOW_SIZE, maxAllowedWidth)
newH = if (isFreeResize) {
(initialHeight - dy).coerceIn(MIN_WINDOW_SIZE, maxAllowedHeight)
} else {
(newW / lockedAspectRatio).toInt()
}
layoutParams.x = initialWinX + (initialWidth - newW)
layoutParams.y = initialWinY + (initialHeight - newH)
}
Gravity.TOP or Gravity.END -> {
newW = (initialWidth + dx).coerceIn(MIN_WINDOW_SIZE, maxAllowedWidth)
newH = if (isFreeResize) {
(initialHeight - dy).coerceIn(MIN_WINDOW_SIZE, maxAllowedHeight)
} else {
(newW / lockedAspectRatio).toInt()
}
layoutParams.y = initialWinY + (initialHeight - newH)
}
Gravity.BOTTOM or Gravity.START -> {
newW = (initialWidth - dx).coerceIn(MIN_WINDOW_SIZE, maxAllowedWidth)
newH = if (isFreeResize) {
(initialHeight + dy).coerceIn(MIN_WINDOW_SIZE, maxAllowedHeight)
} else {
(newW / lockedAspectRatio).toInt()
}
layoutParams.x = initialWinX + (initialWidth - newW)
}
Gravity.BOTTOM or Gravity.END -> {
newW = (initialWidth + dx).coerceIn(MIN_WINDOW_SIZE, maxAllowedWidth)
newH = if (isFreeResize) {
(initialHeight + dy).coerceIn(MIN_WINDOW_SIZE, maxAllowedHeight)
} else {
(newW / lockedAspectRatio).toInt()
}
}
}
// Final aspect-lock check
if (!isFreeResize) {
// Cap height based on both max height and width-constrained limit.
// effectiveMaxHeight helps ensure that the width (newW) calculated below is guaranteed to be <= maxAllowedWidth.
val maxHeightFromWidth = (maxAllowedWidth / lockedAspectRatio).toInt()
val effectiveMaxHeight = minOf(maxAllowedHeight, maxHeightFromWidth)
val clampedH = newH.coerceIn(MIN_WINDOW_SIZE, effectiveMaxHeight)
if (clampedH != newH) {
newH = clampedH
newW = (newH * lockedAspectRatio).toInt()
// Re-adjust pivots for the new clamped dimensions
if (activeCorner == (Gravity.TOP or Gravity.START) || activeCorner == (Gravity.TOP or Gravity.END)) {
layoutParams.y = initialWinY + (initialHeight - newH)
}
if (activeCorner == (Gravity.TOP or Gravity.START) || activeCorner == (Gravity.BOTTOM or Gravity.START)) {
layoutParams.x = initialWinX + (initialWidth - newW)
}
}
}
layoutParams.width = newW
layoutParams.height = newH
val hittingLimit = newW >= maxAllowedWidth || newH >= maxAllowedHeight
lockAspectRatioCheckBox.setTextColor(if (hittingLimit) Color.RED else Color.WHITE)
}
private fun getTouchedCorner(x: Float, y: Float, w: Int, h: Int): Int {
return when {
x < RESIZE_THRESHOLD && y < RESIZE_THRESHOLD -> Gravity.TOP or Gravity.START
x > w - RESIZE_THRESHOLD && y < RESIZE_THRESHOLD -> Gravity.TOP or Gravity.END
x < RESIZE_THRESHOLD && y > h - RESIZE_THRESHOLD -> Gravity.BOTTOM or Gravity.START
x > w - RESIZE_THRESHOLD && y > h - RESIZE_THRESHOLD -> Gravity.BOTTOM or Gravity.END
else -> 0
}
}
private fun saveWindowBounds(updatePiPParams: Boolean = true) {
if (isFullscreen) return
val layoutParams = window.attributes
layoutWidthInPx = layoutParams.width
layoutHeightInPx = layoutParams.height
getSharedPreferences(PREFS_NAME, MODE_PRIVATE).edit().apply {
putInt(KEY_X, layoutParams.x)
putInt(KEY_Y, layoutParams.y)
putInt(KEY_WIDTH, layoutParams.width)
putInt(KEY_HEIGHT, layoutParams.height)
putBoolean(KEY_FREE_RESIZE, isFreeResize)
apply()
}
if (updatePiPParams) {
updatePiPParams(aspectRatio = Rational(layoutParams.width, layoutParams.height))
}
}
private fun loadWindowBounds(layoutParams: WindowManager.LayoutParams) {
val prefs = getSharedPreferences(PREFS_NAME, MODE_PRIVATE)
isFreeResize = prefs.getBoolean(KEY_FREE_RESIZE, false)
layoutWidthInPx = prefs.getInt(KEY_WIDTH, defaultWidthInPx)
layoutHeightInPx = prefs.getInt(KEY_HEIGHT, defaultHeightInPx)
layoutParams.x = prefs.getInt(KEY_X, screenBounds.width() - layoutWidthInPx)
layoutParams.y = prefs.getInt(KEY_Y, screenBounds.height() - layoutHeightInPx)
layoutParams.width = layoutWidthInPx
layoutParams.height = layoutHeightInPx
lockedAspectRatio = layoutParams.width.toFloat() / layoutParams.height.toFloat().coerceAtLeast(1f)
updateLabelText(layoutWidthInPx, layoutHeightInPx)
}
override fun dragGameWindow(view: View, event: MotionEvent): Boolean {
if (isFullscreen) return false
val lp = window.attributes
when (event.actionMasked) {
MotionEvent.ACTION_DOWN -> {
showResizeUI()
gameMenuFragment?.toggleDragButton(true)
initialTouchX = event.rawX
initialTouchY = event.rawY
initialWinX = lp.x
initialWinY = lp.y
return true
}
MotionEvent.ACTION_MOVE -> {
lp.x = (initialWinX + (event.rawX - initialTouchX)).toInt()
lp.y = (initialWinY + (event.rawY - initialTouchY)).toInt()
window.attributes = lp
return true
}
MotionEvent.ACTION_UP -> {
saveWindowBounds(updatePiPParams = false) // Only window position is changed, no need to update aspect ratio.
hideResizeUI()
return true
}
}
return false
}
override fun getEditorWindowInfo() = EMBEDDED_RUN_GAME_INFO
override fun getEditorGameEmbedMode() = GameMenuUtils.GameEmbedMode.ENABLED
override fun isGameEmbedded() = true
private fun updateWindowDimensions(widthInPx: Int, heightInPx: Int) {
val layoutParams = window.attributes
layoutParams.width = widthInPx
layoutParams.height = heightInPx
window.attributes = layoutParams
}
override fun isMinimizedButtonEnabled() = true
override fun isMinimizedButtonEnabled() = isFullscreen
override fun isCloseButtonEnabled() = true
@@ -140,24 +412,40 @@ class EmbeddedGodotGame : GodotGame() {
override fun isMenuBarCollapsable() = false
override fun isAlwaysOnTopSupported() = hasPiPSystemFeature()
override fun isDragButtonEnabled() = !isFullscreen
override fun isAlwaysOnTopSupported() = isPiPModeSupported()
override fun onFullScreenUpdated(enabled: Boolean) {
godot?.enableImmersiveMode(enabled)
isFullscreen = enabled
val layoutParams = window.attributes
if (enabled) {
layoutWidthInPx = FULL_SCREEN_WIDTH
layoutHeightInPx = FULL_SCREEN_HEIGHT
layoutWidthInPx = WindowManager.LayoutParams.MATCH_PARENT
layoutHeightInPx = WindowManager.LayoutParams.MATCH_PARENT
requestedOrientation = gameRequestedOrientation
layoutParams.x = 0
layoutParams.y = 0
if (resizingEnabled) {
disableResizeRunnable.run()
}
} else {
layoutWidthInPx = defaultWidthInPx
layoutHeightInPx = defaultHeightInPx
loadWindowBounds(layoutParams)
// Cache the last used orientation in fullscreen to reapply when re-entering fullscreen.
gameRequestedOrientation = requestedOrientation
requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED
}
updateWindowDimensions(layoutWidthInPx, layoutHeightInPx)
gameMenuFragment?.refreshButtonsVisibility()
}
private fun updateWindowDimensions(widthInPx: Int, heightInPx: Int) {
val layoutParams = window.attributes
layoutParams.width = widthInPx
layoutParams.height = heightInPx
window.attributes = layoutParams
}
override fun onPictureInPictureModeChanged(isInPictureInPictureMode: Boolean) {
@@ -36,6 +36,7 @@ import android.os.Bundle
import android.preference.PreferenceManager
import android.view.LayoutInflater
import android.view.MenuItem
import android.view.MotionEvent
import android.view.View
import android.view.ViewGroup
import android.widget.Button
@@ -47,6 +48,7 @@ import androidx.fragment.app.Fragment
import org.godotengine.editor.BaseGodotEditor
import org.godotengine.editor.BaseGodotEditor.Companion.SNACKBAR_SHOW_DURATION_MS
import org.godotengine.editor.R
import org.godotengine.godot.feature.PictureInPictureProvider
import org.godotengine.godot.utils.DialogUtils
/**
@@ -65,7 +67,7 @@ class GameMenuFragment : Fragment(), PopupMenu.OnMenuItemClickListener {
/**
* Used to be notified of events fired when interacting with the game menu.
*/
interface GameMenuListener {
interface GameMenuListener : PictureInPictureProvider {
/**
* Kotlin representation of the RuntimeNodeSelect::SelectMode enum in 'scene/debugger/scene_debugger.h'.
@@ -109,16 +111,16 @@ class GameMenuFragment : Fragment(), PopupMenu.OnMenuItemClickListener {
fun isGameEmbeddingSupported(): Boolean
fun embedGameOnPlay(embedded: Boolean)
fun enterPiPMode() {}
fun minimizeGameWindow() {}
fun closeGameWindow() {}
fun dragGameWindow(view: View, event: MotionEvent): Boolean { return false}
fun isMinimizedButtonEnabled() = false
fun isFullScreenButtonEnabled() = false
fun isCloseButtonEnabled() = false
fun isPiPButtonEnabled() = false
fun isMenuBarCollapsable() = false
fun isDragButtonEnabled() = false
fun isAlwaysOnTopSupported() = false
fun onFullScreenUpdated(enabled: Boolean) {}
@@ -128,6 +130,9 @@ class GameMenuFragment : Fragment(), PopupMenu.OnMenuItemClickListener {
private val collapseMenuButton: View? by lazy {
view?.findViewById(R.id.game_menu_collapse_button)
}
private val dragButton: View? by lazy {
view?.findViewById(R.id.game_menu_drag_button)
}
private val suspendButton: View? by lazy {
view?.findViewById(R.id.game_menu_suspend_button)
}
@@ -181,7 +186,6 @@ class GameMenuFragment : Fragment(), PopupMenu.OnMenuItemClickListener {
PopupMenu(context, optionsButton).apply {
setOnMenuItemClickListener(this@GameMenuFragment)
inflate(R.menu.options_menu)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
menu.setGroupDividerEnabled(true)
}
@@ -263,6 +267,7 @@ class GameMenuFragment : Fragment(), PopupMenu.OnMenuItemClickListener {
val isCloseButtonEnabled = menuListener?.isCloseButtonEnabled() == true
val isPiPButtonEnabled = menuListener?.isPiPButtonEnabled() == true
val isMenuBarCollapsable = menuListener?.isMenuBarCollapsable() == true
val isDragButtonEnabled = menuListener?.isDragButtonEnabled() == true
// Show the divider if any of the window controls is visible
view.findViewById<View>(R.id.game_menu_window_controls_divider)?.isVisible =
@@ -270,7 +275,8 @@ class GameMenuFragment : Fragment(), PopupMenu.OnMenuItemClickListener {
isFullScreenButtonEnabled ||
isCloseButtonEnabled ||
isPiPButtonEnabled ||
isMenuBarCollapsable
isMenuBarCollapsable ||
isDragButtonEnabled
collapseMenuButton?.apply {
isVisible = isMenuBarCollapsable
@@ -278,6 +284,13 @@ class GameMenuFragment : Fragment(), PopupMenu.OnMenuItemClickListener {
collapseGameMenu()
}
}
dragButton?.apply {
isVisible = isDragButtonEnabled
setOnTouchListener { v, event ->
menuListener?.dragGameWindow(v, event) == true
}
}
fullscreenButton?.apply{
isVisible = isFullScreenButtonEnabled
setOnClickListener {
@@ -445,6 +458,10 @@ class GameMenuFragment : Fragment(), PopupMenu.OnMenuItemClickListener {
internal fun isAlwaysOnTop() = isGameEmbedded && alwaysOnTopChecked
internal fun toggleDragButton(pressed: Boolean) {
dragButton?.isPressed = pressed
}
private fun collapseGameMenu() {
view?.isVisible = false
PreferenceManager.getDefaultSharedPreferences(context).edit {
@@ -461,6 +478,11 @@ class GameMenuFragment : Fragment(), PopupMenu.OnMenuItemClickListener {
menuListener?.onGameMenuCollapsed(false)
}
internal fun refreshButtonsVisibility() {
minimizeButton?.isVisible = menuListener?.isMinimizedButtonEnabled() == true
dragButton?.isVisible = menuListener?.isDragButtonEnabled() == true
}
private fun updateAlwaysOnTop(enabled: Boolean) {
alwaysOnTopChecked = enabled
PreferenceManager.getDefaultSharedPreferences(context).edit {
@@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M480,880L310,710L367,653L440,726L440,520L235,520L308,592L250,650L80,480L249,311L306,368L234,440L440,440L440,234L367,307L310,250L480,80L650,250L593,307L520,234L520,440L725,440L652,368L710,310L880,480L710,650L653,593L726,520L520,520L520,725L592,652L650,710L480,880Z"/>
</vector>
@@ -0,0 +1,13 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:pathData="M20,20 L20,4 M20,20 L4,20"
android:strokeColor="#007AFF"
android:strokeWidth="4"
android:strokeLineCap="round"
android:strokeLineJoin="round"/>
</vector>
@@ -181,6 +181,15 @@
android:src="@drawable/baseline_expand_less_24"
/>
<ImageButton
android:id="@+id/game_menu_drag_button"
style="?android:attr/borderlessButtonStyle"
android:layout_width="48dp"
android:layout_height="48dp"
android:background="@drawable/game_menu_selected_button_bg"
android:src="@drawable/drag_pan_24px"
/>
<ImageButton
android:id="@+id/game_menu_minimize_button"
style="?android:attr/borderlessButtonStyle"
@@ -0,0 +1,72 @@
<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent">
<FrameLayout
android:id="@+id/game_menu_fragment_container"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_alignParentTop="true"
/>
<FrameLayout
android:id="@+id/godot_fragment_container"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_below="@+id/game_menu_fragment_container"/>
<CheckBox
android:id="@+id/lockAspectRatioCheckBox"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_centerHorizontal="true"
android:layout_marginTop="60dp"
android:background="#CC000000"
android:padding="8dp"
android:text="Lock Aspect Ratio"
android:textColor="#FFFFFF"
android:buttonTint="#FFFFFF"
android:checked="true"
android:visibility="gone" />
<View
android:id="@+id/handleTopLeft"
android:layout_width="80px"
android:layout_height="80px"
android:layout_alignParentStart="true"
android:layout_alignParentTop="true"
android:background="@drawable/resize_handle"
android:rotation="180"
android:visibility="gone" />
<View
android:id="@+id/handleTopRight"
android:layout_width="80px"
android:layout_height="80px"
android:layout_alignParentEnd="true"
android:layout_alignParentTop="true"
android:background="@drawable/resize_handle"
android:rotation="270"
android:visibility="gone" />
<View
android:id="@+id/handleBottomLeft"
android:layout_width="80px"
android:layout_height="80px"
android:layout_alignParentStart="true"
android:layout_alignParentBottom="true"
android:background="@drawable/resize_handle"
android:rotation="90"
android:visibility="gone" />
<View
android:id="@+id/handleBottomRight"
android:layout_width="80px"
android:layout_height="80px"
android:layout_alignParentEnd="true"
android:layout_alignParentBottom="true"
android:background="@drawable/resize_handle"
android:visibility="gone" />
</RelativeLayout>
@@ -20,4 +20,5 @@
<string name="show_game_resume_hint">Tap on \'Game\' to resume</string>
<string name="restart_embed_game_hint">Restart game to embed</string>
<string name="restart_non_embedded_game_hint">Restart Game to disable embedding</string>
<string name="lock_aspect_ratio_btn_text">Lock Aspect ratio (%d x %d)</string>
</resources>
+7 -17
View File
@@ -13,7 +13,7 @@ apply from: "../scripts/publish-module.gradle"
dependencies {
implementation "androidx.fragment:fragment:$versions.fragmentVersion"
implementation 'androidx.documentfile:documentfile:1.1.0'
implementation "androidx.documentfile:documentfile:$versions.documentfileVersion"
testImplementation "junit:junit:4.13.2"
}
@@ -48,12 +48,6 @@ android {
buildConfig = true
}
buildTypes {
dev {
initWith debug
}
}
flavorDimensions = ["products"]
productFlavors {
editor {}
@@ -79,13 +73,11 @@ android {
sourceSets {
debug.jniLibs.srcDirs = ['libs/debug']
dev.jniLibs.srcDirs = ['libs/dev']
release.jniLibs.srcDirs = ['libs/release']
// Editor jni library
editorRelease.jniLibs.srcDirs = ['libs/tools/release']
editorDebug.jniLibs.srcDirs = ['libs/tools/debug']
editorDev.jniLibs.srcDirs = ['libs/tools/dev']
}
libraryVariants.all { variant ->
@@ -99,9 +91,9 @@ android {
throw new GradleException("Invalid build type: $buildType")
}
boolean devBuild = buildType == "dev"
boolean debugSymbols = devBuild
boolean runTests = devBuild
boolean debugBuild = buildType == "debug"
boolean debugSymbols = debugBuild
boolean runTests = debugBuild
boolean storeRelease = buildType == "release"
boolean productionBuild = storeRelease
@@ -109,12 +101,13 @@ android {
if (sconsTarget == "template") {
// Tests are not supported on template builds
runTests = false
//noinspection GroovyFallthrough
switch (buildType) {
case "release":
sconsTarget += "_release"
break
case "debug":
case "dev":
default:
sconsTarget += "_debug"
break
@@ -123,9 +116,6 @@ android {
// Update the name of the generated library
def outputSuffix = "${sconsTarget}"
if (devBuild) {
outputSuffix = "${outputSuffix}.dev"
}
variant.outputs.all { output ->
output.outputFileName = "godot-lib.${outputSuffix}.aar"
}
@@ -168,7 +158,7 @@ android {
def taskName = getSconsTaskName(flavorName, buildType, selectedAbi)
tasks.create(name: taskName, type: Exec) {
executable sconsExecutableFile.absolutePath
args "--directory=${pathToRootDir}", "platform=android", "store_release=${storeRelease}", "production=${productionBuild}", "dev_mode=${devBuild}", "dev_build=${devBuild}", "debug_symbols=${debugSymbols}", "tests=${runTests}", "target=${sconsTarget}", "arch=${selectedAbi}", "-j" + Runtime.runtime.availableProcessors()
args "--directory=${pathToRootDir}", "platform=android", "store_release=${storeRelease}", "production=${productionBuild}", "dev_mode=${debugBuild}", "dev_build=${debugBuild}", "debug_symbols=${debugSymbols}", "tests=${runTests}", "target=${sconsTarget}", "arch=${selectedAbi}", "-j" + Runtime.runtime.availableProcessors()
}
// Schedule the tasks so the generated libs are present before the aar file is packaged.
@@ -1,300 +0,0 @@
diff --git a/platform/android/java/src/com/google/android/vending/expansion/downloader/DownloaderClientMarshaller.java b/platform/android/java/src/com/google/android/vending/expansion/downloader/DownloaderClientMarshaller.java
index ad6ea0de6..452c7d148 100644
--- a/platform/android/java/src/com/google/android/vending/expansion/downloader/DownloaderClientMarshaller.java
+++ b/platform/android/java/src/com/google/android/vending/expansion/downloader/DownloaderClientMarshaller.java
@@ -32,6 +32,9 @@ import android.os.Messenger;
import android.os.RemoteException;
import android.util.Log;
+// -- GODOT start --
+import java.lang.ref.WeakReference;
+// -- GODOT end --
/**
@@ -118,29 +121,46 @@ public class DownloaderClientMarshaller {
/**
* Target we publish for clients to send messages to IncomingHandler.
*/
- final Messenger mMessenger = new Messenger(new Handler() {
+ // -- GODOT start --
+ private final MessengerHandlerClient mMsgHandler = new MessengerHandlerClient(this);
+ final Messenger mMessenger = new Messenger(mMsgHandler);
+
+ private static class MessengerHandlerClient extends Handler {
+ private final WeakReference<Stub> mDownloader;
+ public MessengerHandlerClient(Stub downloader) {
+ mDownloader = new WeakReference<>(downloader);
+ }
+
@Override
public void handleMessage(Message msg) {
- switch (msg.what) {
- case MSG_ONDOWNLOADPROGRESS:
- Bundle bun = msg.getData();
- if ( null != mContext ) {
- bun.setClassLoader(mContext.getClassLoader());
- DownloadProgressInfo dpi = (DownloadProgressInfo) msg.getData()
- .getParcelable(PARAM_PROGRESS);
- mItf.onDownloadProgress(dpi);
- }
- break;
- case MSG_ONDOWNLOADSTATE_CHANGED:
- mItf.onDownloadStateChanged(msg.getData().getInt(PARAM_NEW_STATE));
- break;
- case MSG_ONSERVICECONNECTED:
- mItf.onServiceConnected(
- (Messenger) msg.getData().getParcelable(PARAM_MESSENGER));
- break;
+ Stub downloader = mDownloader.get();
+ if (downloader != null) {
+ downloader.handleMessage(msg);
}
}
- });
+ }
+
+ private void handleMessage(Message msg) {
+ switch (msg.what) {
+ case MSG_ONDOWNLOADPROGRESS:
+ Bundle bun = msg.getData();
+ if (null != mContext) {
+ bun.setClassLoader(mContext.getClassLoader());
+ DownloadProgressInfo dpi = (DownloadProgressInfo)msg.getData()
+ .getParcelable(PARAM_PROGRESS);
+ mItf.onDownloadProgress(dpi);
+ }
+ break;
+ case MSG_ONDOWNLOADSTATE_CHANGED:
+ mItf.onDownloadStateChanged(msg.getData().getInt(PARAM_NEW_STATE));
+ break;
+ case MSG_ONSERVICECONNECTED:
+ mItf.onServiceConnected(
+ (Messenger)msg.getData().getParcelable(PARAM_MESSENGER));
+ break;
+ }
+ }
+ // -- GODOT end --
public Stub(IDownloaderClient itf, Class<?> downloaderService) {
mItf = itf;
diff --git a/platform/android/java/src/com/google/android/vending/expansion/downloader/DownloaderServiceMarshaller.java b/platform/android/java/src/com/google/android/vending/expansion/downloader/DownloaderServiceMarshaller.java
index 979352299..3771d19c9 100644
--- a/platform/android/java/src/com/google/android/vending/expansion/downloader/DownloaderServiceMarshaller.java
+++ b/platform/android/java/src/com/google/android/vending/expansion/downloader/DownloaderServiceMarshaller.java
@@ -25,6 +25,9 @@ import android.os.Message;
import android.os.Messenger;
import android.os.RemoteException;
+// -- GODOT start --
+import java.lang.ref.WeakReference;
+// -- GODOT end --
/**
@@ -108,32 +111,49 @@ public class DownloaderServiceMarshaller {
private static class Stub implements IStub {
private IDownloaderService mItf = null;
- final Messenger mMessenger = new Messenger(new Handler() {
+ // -- GODOT start --
+ private final MessengerHandlerServer mMsgHandler = new MessengerHandlerServer(this);
+ final Messenger mMessenger = new Messenger(mMsgHandler);
+
+ private static class MessengerHandlerServer extends Handler {
+ private final WeakReference<Stub> mDownloader;
+ public MessengerHandlerServer(Stub downloader) {
+ mDownloader = new WeakReference<>(downloader);
+ }
+
@Override
public void handleMessage(Message msg) {
- switch (msg.what) {
- case MSG_REQUEST_ABORT_DOWNLOAD:
- mItf.requestAbortDownload();
- break;
- case MSG_REQUEST_CONTINUE_DOWNLOAD:
- mItf.requestContinueDownload();
- break;
- case MSG_REQUEST_PAUSE_DOWNLOAD:
- mItf.requestPauseDownload();
- break;
- case MSG_SET_DOWNLOAD_FLAGS:
- mItf.setDownloadFlags(msg.getData().getInt(PARAMS_FLAGS));
- break;
- case MSG_REQUEST_DOWNLOAD_STATE:
- mItf.requestDownloadStatus();
- break;
- case MSG_REQUEST_CLIENT_UPDATE:
- mItf.onClientUpdated((Messenger) msg.getData().getParcelable(
- PARAM_MESSENGER));
- break;
+ Stub downloader = mDownloader.get();
+ if (downloader != null) {
+ downloader.handleMessage(msg);
}
}
- });
+ }
+
+ private void handleMessage(Message msg) {
+ switch (msg.what) {
+ case MSG_REQUEST_ABORT_DOWNLOAD:
+ mItf.requestAbortDownload();
+ break;
+ case MSG_REQUEST_CONTINUE_DOWNLOAD:
+ mItf.requestContinueDownload();
+ break;
+ case MSG_REQUEST_PAUSE_DOWNLOAD:
+ mItf.requestPauseDownload();
+ break;
+ case MSG_SET_DOWNLOAD_FLAGS:
+ mItf.setDownloadFlags(msg.getData().getInt(PARAMS_FLAGS));
+ break;
+ case MSG_REQUEST_DOWNLOAD_STATE:
+ mItf.requestDownloadStatus();
+ break;
+ case MSG_REQUEST_CLIENT_UPDATE:
+ mItf.onClientUpdated((Messenger)msg.getData().getParcelable(
+ PARAM_MESSENGER));
+ break;
+ }
+ }
+ // -- GODOT end --
public Stub(IDownloaderService itf) {
mItf = itf;
diff --git a/platform/android/java/src/com/google/android/vending/expansion/downloader/Helpers.java b/platform/android/java/src/com/google/android/vending/expansion/downloader/Helpers.java
index e4b1b0f1c..36cd6aacf 100644
--- a/platform/android/java/src/com/google/android/vending/expansion/downloader/Helpers.java
+++ b/platform/android/java/src/com/google/android/vending/expansion/downloader/Helpers.java
@@ -24,7 +24,10 @@ import android.os.StatFs;
import android.os.SystemClock;
import android.util.Log;
-import com.android.vending.expansion.downloader.R;
+// -- GODOT start --
+//import com.android.vending.expansion.downloader.R;
+import org.godotengine.godot.R;
+// -- GODOT end --
import java.io.File;
import java.text.SimpleDateFormat;
@@ -146,12 +149,14 @@ public class Helpers {
}
return "";
}
- return String.format("%.2f",
+ // -- GODOT start --
+ return String.format(Locale.ENGLISH, "%.2f",
(float) overallProgress / (1024.0f * 1024.0f))
+ "MB /" +
- String.format("%.2f", (float) overallTotal /
+ String.format(Locale.ENGLISH, "%.2f", (float) overallTotal /
(1024.0f * 1024.0f))
+ "MB";
+ // -- GODOT end --
}
/**
@@ -184,7 +189,9 @@ public class Helpers {
}
public static String getSpeedString(float bytesPerMillisecond) {
- return String.format("%.2f", bytesPerMillisecond * 1000 / 1024);
+ // -- GODOT start --
+ return String.format(Locale.ENGLISH, "%.2f", bytesPerMillisecond * 1000 / 1024);
+ // -- GODOT end --
}
public static String getTimeRemaining(long durationInMilliseconds) {
diff --git a/platform/android/java/src/com/google/android/vending/expansion/downloader/SystemFacade.java b/platform/android/java/src/com/google/android/vending/expansion/downloader/SystemFacade.java
index 12edd97ab..a0e1165cc 100644
--- a/platform/android/java/src/com/google/android/vending/expansion/downloader/SystemFacade.java
+++ b/platform/android/java/src/com/google/android/vending/expansion/downloader/SystemFacade.java
@@ -26,6 +26,10 @@ import android.net.NetworkInfo;
import android.telephony.TelephonyManager;
import android.util.Log;
+// -- GODOT start --
+import android.annotation.SuppressLint;
+// -- GODOT end --
+
/**
* Contains useful helper functions, typically tied to the application context.
*/
@@ -51,6 +55,7 @@ class SystemFacade {
return null;
}
+ @SuppressLint("MissingPermission")
NetworkInfo activeInfo = connectivity.getActiveNetworkInfo();
if (activeInfo == null) {
if (Constants.LOGVV) {
@@ -69,6 +74,7 @@ class SystemFacade {
return false;
}
+ @SuppressLint("MissingPermission")
NetworkInfo info = connectivity.getActiveNetworkInfo();
boolean isMobile = (info != null && info.getType() == ConnectivityManager.TYPE_MOBILE);
TelephonyManager tm = (TelephonyManager) mContext
diff --git a/platform/android/java/src/com/google/android/vending/expansion/downloader/impl/DownloadNotification.java b/platform/android/java/src/com/google/android/vending/expansion/downloader/impl/DownloadNotification.java
index f1536e80e..4b214b22d 100644
--- a/platform/android/java/src/com/google/android/vending/expansion/downloader/impl/DownloadNotification.java
+++ b/platform/android/java/src/com/google/android/vending/expansion/downloader/impl/DownloadNotification.java
@@ -16,7 +16,11 @@
package com.google.android.vending.expansion.downloader.impl;
-import com.android.vending.expansion.downloader.R;
+// -- GODOT start --
+//import com.android.vending.expansion.downloader.R;
+import org.godotengine.godot.R;
+// -- GODOT end --
+
import com.google.android.vending.expansion.downloader.DownloadProgressInfo;
import com.google.android.vending.expansion.downloader.DownloaderClientMarshaller;
import com.google.android.vending.expansion.downloader.Helpers;
diff --git a/platform/android/java/src/com/google/android/vending/expansion/downloader/impl/DownloadThread.java b/platform/android/java/src/com/google/android/vending/expansion/downloader/impl/DownloadThread.java
index b2e0e7af0..c114b8a64 100644
--- a/platform/android/java/src/com/google/android/vending/expansion/downloader/impl/DownloadThread.java
+++ b/platform/android/java/src/com/google/android/vending/expansion/downloader/impl/DownloadThread.java
@@ -146,8 +146,12 @@ public class DownloadThread {
try {
PowerManager pm = (PowerManager) mContext.getSystemService(Context.POWER_SERVICE);
- wakeLock = pm.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, Constants.TAG);
- wakeLock.acquire();
+ // -- GODOT start --
+ //wakeLock = pm.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, Constants.TAG);
+ //wakeLock.acquire();
+ wakeLock = pm.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "org.godot.game:wakelock");
+ wakeLock.acquire(20 * 60 * 1000L /*20 minutes*/);
+ // -- GODOT end --
if (Constants.LOGV) {
Log.v(Constants.TAG, "initiating download for " + mInfo.mFileName);
diff --git a/platform/android/java/src/com/google/android/vending/expansion/downloader/impl/DownloaderService.java b/platform/android/java/src/com/google/android/vending/expansion/downloader/impl/DownloaderService.java
index 4babe476f..8d41a7690 100644
--- a/platform/android/java/src/com/google/android/vending/expansion/downloader/impl/DownloaderService.java
+++ b/platform/android/java/src/com/google/android/vending/expansion/downloader/impl/DownloaderService.java
@@ -50,6 +50,10 @@ import android.provider.Settings.Secure;
import android.telephony.TelephonyManager;
import android.util.Log;
+// -- GODOT start --
+import android.annotation.SuppressLint;
+// -- GODOT end --
+
import java.io.File;
/**
@@ -578,6 +582,7 @@ public abstract class DownloaderService extends CustomIntentService implements I
Log.w(Constants.TAG,
"couldn't get connectivity manager to poll network state");
} else {
+ @SuppressLint("MissingPermission")
NetworkInfo activeInfo = mConnectivityManager
.getActiveNetworkInfo();
updateNetworkState(activeInfo);
@@ -1,42 +0,0 @@
diff --git a/platform/android/java/src/com/google/android/vending/licensing/PreferenceObfuscator.java b/platform/android/java/src/com/google/android/vending/licensing/PreferenceObfuscator.java
index 7c42bfc28..feb579af0 100644
--- a/platform/android/java/src/com/google/android/vending/licensing/PreferenceObfuscator.java
+++ b/platform/android/java/src/com/google/android/vending/licensing/PreferenceObfuscator.java
@@ -45,6 +45,9 @@ public class PreferenceObfuscator {
public void putString(String key, String value) {
if (mEditor == null) {
mEditor = mPreferences.edit();
+ // -- GODOT start --
+ mEditor.apply();
+ // -- GODOT end --
}
String obfuscatedValue = mObfuscator.obfuscate(value, key);
mEditor.putString(key, obfuscatedValue);
diff --git a/platform/android/java/src/com/google/android/vending/licensing/util/Base64.java b/platform/android/java/src/com/google/android/vending/licensing/util/Base64.java
index a0d2779af..a8bf65f9c 100644
--- a/platform/android/java/src/com/google/android/vending/licensing/util/Base64.java
+++ b/platform/android/java/src/com/google/android/vending/licensing/util/Base64.java
@@ -31,6 +31,10 @@ package com.google.android.vending.licensing.util;
* @version 1.3
*/
+// -- GODOT start --
import org.godotengine.godot.BuildConfig;
+// -- GODOT end --
+
/**
* Base64 converter class. This code is not a full-blown MIME encoder;
* it simply converts binary data to base64 data and back.
@@ -341,7 +345,11 @@ public class Base64 {
e += 4;
}
- assert (e == outBuff.length);
+ // -- GODOT start --
+ //assert (e == outBuff.length);
+ if (BuildConfig.DEBUG && e != outBuff.length)
+ throw new RuntimeException();
+ // -- GODOT end --
return outBuff;
}
@@ -10,8 +10,6 @@
android:name="org.godotengine.library.version"
android:value="${godotLibraryVersion}" />
<service android:name=".GodotDownloaderService" />
<activity
android:name=".utils.ProcessPhoenix"
android:theme="@android:style/Theme.Translucent.NoTitleBar"
@@ -1,21 +0,0 @@
/*
* Copyright (C) 2010 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.android.vending.licensing;
oneway interface ILicenseResultListener {
void verifyLicense(int responseCode, String signedData, String signature);
}
@@ -1,23 +0,0 @@
/*
* Copyright (C) 2010 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.android.vending.licensing;
import com.android.vending.licensing.ILicenseResultListener;
oneway interface ILicensingService {
void checkLicense(long nonce, String packageName, in ILicenseResultListener listener);
}
@@ -1,236 +0,0 @@
/*
* Copyright (C) 2012 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.google.android.vending.expansion.downloader;
import java.io.File;
/**
* Contains the internal constants that are used in the download manager.
* As a general rule, modifying these constants should be done with care.
*/
public class Constants {
/** Tag used for debugging/logging */
public static final String TAG = "LVLDL";
/**
* Expansion path where we store obb files
*/
public static final String EXP_PATH = File.separator + "Android"
+ File.separator + "obb" + File.separator;
/** The intent that gets sent when the service must wake up for a retry */
public static final String ACTION_RETRY = "android.intent.action.DOWNLOAD_WAKEUP";
/** the intent that gets sent when clicking a successful download */
public static final String ACTION_OPEN = "android.intent.action.DOWNLOAD_OPEN";
/** the intent that gets sent when clicking an incomplete/failed download */
public static final String ACTION_LIST = "android.intent.action.DOWNLOAD_LIST";
/** the intent that gets sent when deleting the notification of a completed download */
public static final String ACTION_HIDE = "android.intent.action.DOWNLOAD_HIDE";
/**
* When a number has to be appended to the filename, this string is used to separate the
* base filename from the sequence number
*/
public static final String FILENAME_SEQUENCE_SEPARATOR = "-";
/** The default user agent used for downloads */
public static final String DEFAULT_USER_AGENT = "Android.LVLDM";
/** The buffer size used to stream the data */
public static final int BUFFER_SIZE = 4096;
/** The minimum amount of progress that has to be done before the progress bar gets updated */
public static final int MIN_PROGRESS_STEP = 4096;
/** The minimum amount of time that has to elapse before the progress bar gets updated, in ms */
public static final long MIN_PROGRESS_TIME = 1000;
/** The maximum number of rows in the database (FIFO) */
public static final int MAX_DOWNLOADS = 1000;
/**
* The number of times that the download manager will retry its network
* operations when no progress is happening before it gives up.
*/
public static final int MAX_RETRIES = 5;
/**
* The minimum amount of time that the download manager accepts for
* a Retry-After response header with a parameter in delta-seconds.
*/
public static final int MIN_RETRY_AFTER = 30; // 30s
/**
* The maximum amount of time that the download manager accepts for
* a Retry-After response header with a parameter in delta-seconds.
*/
public static final int MAX_RETRY_AFTER = 24 * 60 * 60; // 24h
/**
* The maximum number of redirects.
*/
public static final int MAX_REDIRECTS = 5; // can't be more than 7.
/**
* The time between a failure and the first retry after an IOException.
* Each subsequent retry grows exponentially, doubling each time.
* The time is in seconds.
*/
public static final int RETRY_FIRST_DELAY = 30;
/** Enable separate connectivity logging */
public static final boolean LOGX = true;
/** Enable verbose logging */
public static final boolean LOGV = false;
/** Enable super-verbose logging */
private static final boolean LOCAL_LOGVV = false;
public static final boolean LOGVV = LOCAL_LOGVV && LOGV;
/**
* This download has successfully completed.
* Warning: there might be other status values that indicate success
* in the future.
* Use isSucccess() to capture the entire category.
*/
public static final int STATUS_SUCCESS = 200;
/**
* This request couldn't be parsed. This is also used when processing
* requests with unknown/unsupported URI schemes.
*/
public static final int STATUS_BAD_REQUEST = 400;
/**
* This download can't be performed because the content type cannot be
* handled.
*/
public static final int STATUS_NOT_ACCEPTABLE = 406;
/**
* This download cannot be performed because the length cannot be
* determined accurately. This is the code for the HTTP error "Length
* Required", which is typically used when making requests that require
* a content length but don't have one, and it is also used in the
* client when a response is received whose length cannot be determined
* accurately (therefore making it impossible to know when a download
* completes).
*/
public static final int STATUS_LENGTH_REQUIRED = 411;
/**
* This download was interrupted and cannot be resumed.
* This is the code for the HTTP error "Precondition Failed", and it is
* also used in situations where the client doesn't have an ETag at all.
*/
public static final int STATUS_PRECONDITION_FAILED = 412;
/**
* The lowest-valued error status that is not an actual HTTP status code.
*/
public static final int MIN_ARTIFICIAL_ERROR_STATUS = 488;
/**
* The requested destination file already exists.
*/
public static final int STATUS_FILE_ALREADY_EXISTS_ERROR = 488;
/**
* Some possibly transient error occurred, but we can't resume the download.
*/
public static final int STATUS_CANNOT_RESUME = 489;
/**
* This download was canceled
*/
public static final int STATUS_CANCELED = 490;
/**
* This download has completed with an error.
* Warning: there will be other status values that indicate errors in
* the future. Use isStatusError() to capture the entire category.
*/
public static final int STATUS_UNKNOWN_ERROR = 491;
/**
* This download couldn't be completed because of a storage issue.
* Typically, that's because the filesystem is missing or full.
* Use the more specific {@link #STATUS_INSUFFICIENT_SPACE_ERROR}
* and {@link #STATUS_DEVICE_NOT_FOUND_ERROR} when appropriate.
*/
public static final int STATUS_FILE_ERROR = 492;
/**
* This download couldn't be completed because of an HTTP
* redirect response that the download manager couldn't
* handle.
*/
public static final int STATUS_UNHANDLED_REDIRECT = 493;
/**
* This download couldn't be completed because of an
* unspecified unhandled HTTP code.
*/
public static final int STATUS_UNHANDLED_HTTP_CODE = 494;
/**
* This download couldn't be completed because of an
* error receiving or processing data at the HTTP level.
*/
public static final int STATUS_HTTP_DATA_ERROR = 495;
/**
* This download couldn't be completed because of an
* HttpException while setting up the request.
*/
public static final int STATUS_HTTP_EXCEPTION = 496;
/**
* This download couldn't be completed because there were
* too many redirects.
*/
public static final int STATUS_TOO_MANY_REDIRECTS = 497;
/**
* This download couldn't be completed due to insufficient storage
* space. Typically, this is because the SD card is full.
*/
public static final int STATUS_INSUFFICIENT_SPACE_ERROR = 498;
/**
* This download couldn't be completed because no external storage
* device was found. Typically, this is because the SD card is not
* mounted.
*/
public static final int STATUS_DEVICE_NOT_FOUND_ERROR = 499;
/**
* The wake duration to check to see if a download is possible.
*/
public static final long WATCHDOG_WAKE_TIMER = 60*1000;
/**
* The wake duration to check to see if the process was killed.
*/
public static final long ACTIVE_THREAD_WATCHDOG = 5*1000;
}
@@ -1,80 +0,0 @@
/*
* Copyright (C) 2012 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.google.android.vending.expansion.downloader;
import android.os.Parcel;
import android.os.Parcelable;
/**
* This class contains progress information about the active download(s).
*
* When you build the Activity that initiates a download and tracks the
* progress by implementing the {@link IDownloaderClient} interface, you'll
* receive a DownloadProgressInfo object in each call to the {@link
* IDownloaderClient#onDownloadProgress} method. This allows you to update
* your activity's UI with information about the download progress, such
* as the progress so far, time remaining and current speed.
*/
public class DownloadProgressInfo implements Parcelable {
public long mOverallTotal;
public long mOverallProgress;
public long mTimeRemaining; // time remaining
public float mCurrentSpeed; // speed in KB/S
@Override
public int describeContents() {
return 0;
}
@Override
public void writeToParcel(Parcel p, int i) {
p.writeLong(mOverallTotal);
p.writeLong(mOverallProgress);
p.writeLong(mTimeRemaining);
p.writeFloat(mCurrentSpeed);
}
public DownloadProgressInfo(Parcel p) {
mOverallTotal = p.readLong();
mOverallProgress = p.readLong();
mTimeRemaining = p.readLong();
mCurrentSpeed = p.readFloat();
}
public DownloadProgressInfo(long overallTotal, long overallProgress,
long timeRemaining,
float currentSpeed) {
this.mOverallTotal = overallTotal;
this.mOverallProgress = overallProgress;
this.mTimeRemaining = timeRemaining;
this.mCurrentSpeed = currentSpeed;
}
public static final Creator<DownloadProgressInfo> CREATOR = new Creator<DownloadProgressInfo>() {
@Override
public DownloadProgressInfo createFromParcel(Parcel parcel) {
return new DownloadProgressInfo(parcel);
}
@Override
public DownloadProgressInfo[] newArray(int i) {
return new DownloadProgressInfo[i];
}
};
}
@@ -1,297 +0,0 @@
/*
* Copyright (C) 2012 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.google.android.vending.expansion.downloader;
import com.google.android.vending.expansion.downloader.impl.DownloaderService;
import android.app.PendingIntent;
import android.content.ComponentName;
import android.content.Context;
import android.content.Intent;
import android.content.ServiceConnection;
import android.content.pm.PackageManager.NameNotFoundException;
import android.os.Bundle;
import android.os.Handler;
import android.os.IBinder;
import android.os.Message;
import android.os.Messenger;
import android.os.RemoteException;
import android.util.Log;
// -- GODOT start --
import java.lang.ref.WeakReference;
// -- GODOT end --
/**
* This class binds the service API to your application client. It contains the IDownloaderClient proxy,
* which is used to call functions in your client as well as the Stub, which is used to call functions
* in the client implementation of IDownloaderClient.
*
* <p>The IPC is implemented using an Android Messenger and a service Binder. The connect method
* should be called whenever the client wants to bind to the service. It opens up a service connection
* that ends up calling the onServiceConnected client API that passes the service messenger
* in. If the client wants to be notified by the service, it is responsible for then passing its
* messenger to the service in a separate call.
*
* <p>Critical methods are {@link #startDownloadServiceIfRequired} and {@link #CreateStub}.
*
* <p>When your application first starts, you should first check whether your app's expansion files are
* already on the device. If not, you should then call {@link #startDownloadServiceIfRequired}, which
* starts your {@link impl.DownloaderService} to download the expansion files if necessary. The method
* returns a value indicating whether download is required or not.
*
* <p>If a download is required, {@link #startDownloadServiceIfRequired} begins the download through
* the specified service and you should then call {@link #CreateStub} to instantiate a member {@link
* IStub} object that you need in order to receive calls through your {@link IDownloaderClient}
* interface.
*/
public class DownloaderClientMarshaller {
public static final int MSG_ONDOWNLOADSTATE_CHANGED = 10;
public static final int MSG_ONDOWNLOADPROGRESS = 11;
public static final int MSG_ONSERVICECONNECTED = 12;
public static final String PARAM_NEW_STATE = "newState";
public static final String PARAM_PROGRESS = "progress";
public static final String PARAM_MESSENGER = DownloaderService.EXTRA_MESSAGE_HANDLER;
public static final int NO_DOWNLOAD_REQUIRED = DownloaderService.NO_DOWNLOAD_REQUIRED;
public static final int LVL_CHECK_REQUIRED = DownloaderService.LVL_CHECK_REQUIRED;
public static final int DOWNLOAD_REQUIRED = DownloaderService.DOWNLOAD_REQUIRED;
private static class Proxy implements IDownloaderClient {
private Messenger mServiceMessenger;
@Override
public void onDownloadStateChanged(int newState) {
Bundle params = new Bundle(1);
params.putInt(PARAM_NEW_STATE, newState);
send(MSG_ONDOWNLOADSTATE_CHANGED, params);
}
@Override
public void onDownloadProgress(DownloadProgressInfo progress) {
Bundle params = new Bundle(1);
params.putParcelable(PARAM_PROGRESS, progress);
send(MSG_ONDOWNLOADPROGRESS, params);
}
private void send(int method, Bundle params) {
Message m = Message.obtain(null, method);
m.setData(params);
try {
mServiceMessenger.send(m);
} catch (RemoteException e) {
e.printStackTrace();
}
}
public Proxy(Messenger msg) {
mServiceMessenger = msg;
}
@Override
public void onServiceConnected(Messenger m) {
/**
* This is never called through the proxy.
*/
}
}
private static class Stub implements IStub {
private IDownloaderClient mItf = null;
private Class<?> mDownloaderServiceClass;
private boolean mBound;
private Messenger mServiceMessenger;
private Context mContext;
/**
* Target we publish for clients to send messages to IncomingHandler.
*/
// -- GODOT start --
private final MessengerHandlerClient mMsgHandler = new MessengerHandlerClient(this);
final Messenger mMessenger = new Messenger(mMsgHandler);
private static class MessengerHandlerClient extends Handler {
private final WeakReference<Stub> mDownloader;
public MessengerHandlerClient(Stub downloader) {
mDownloader = new WeakReference<>(downloader);
}
@Override
public void handleMessage(Message msg) {
Stub downloader = mDownloader.get();
if (downloader != null) {
downloader.handleMessage(msg);
}
}
}
private void handleMessage(Message msg) {
switch (msg.what) {
case MSG_ONDOWNLOADPROGRESS:
Bundle bun = msg.getData();
if (null != mContext) {
bun.setClassLoader(mContext.getClassLoader());
DownloadProgressInfo dpi = (DownloadProgressInfo)msg.getData()
.getParcelable(PARAM_PROGRESS);
mItf.onDownloadProgress(dpi);
}
break;
case MSG_ONDOWNLOADSTATE_CHANGED:
mItf.onDownloadStateChanged(msg.getData().getInt(PARAM_NEW_STATE));
break;
case MSG_ONSERVICECONNECTED:
mItf.onServiceConnected(
(Messenger)msg.getData().getParcelable(PARAM_MESSENGER));
break;
}
}
// -- GODOT end --
public Stub(IDownloaderClient itf, Class<?> downloaderService) {
mItf = itf;
mDownloaderServiceClass = downloaderService;
}
/**
* Class for interacting with the main interface of the service.
*/
private ServiceConnection mConnection = new ServiceConnection() {
public void onServiceConnected(ComponentName className, IBinder service) {
// This is called when the connection with the service has been
// established, giving us the object we can use to
// interact with the service. We are communicating with the
// service using a Messenger, so here we get a client-side
// representation of that from the raw IBinder object.
mServiceMessenger = new Messenger(service);
mItf.onServiceConnected(
mServiceMessenger);
}
public void onServiceDisconnected(ComponentName className) {
// This is called when the connection with the service has been
// unexpectedly disconnected -- that is, its process crashed.
mServiceMessenger = null;
}
};
@Override
public void connect(Context c) {
mContext = c;
Intent bindIntent = new Intent(c, mDownloaderServiceClass);
bindIntent.putExtra(PARAM_MESSENGER, mMessenger);
if ( !c.bindService(bindIntent, mConnection, Context.BIND_DEBUG_UNBIND) ) {
if ( Constants.LOGVV ) {
Log.d(Constants.TAG, "Service Unbound");
}
} else {
mBound = true;
}
}
@Override
public void disconnect(Context c) {
if (mBound) {
c.unbindService(mConnection);
mBound = false;
}
mContext = null;
}
@Override
public Messenger getMessenger() {
return mMessenger;
}
}
/**
* Returns a proxy that will marshal calls to IDownloaderClient methods
*
* @param msg
* @return
*/
public static IDownloaderClient CreateProxy(Messenger msg) {
return new Proxy(msg);
}
/**
* Returns a stub object that, when connected, will listen for marshaled
* {@link IDownloaderClient} methods and translate them into calls to the supplied
* interface.
*
* @param itf An implementation of IDownloaderClient that will be called
* when remote method calls are unmarshaled.
* @param downloaderService The class for your implementation of {@link
* impl.DownloaderService}.
* @return The {@link IStub} that allows you to connect to the service such that
* your {@link IDownloaderClient} receives status updates.
*/
public static IStub CreateStub(IDownloaderClient itf, Class<?> downloaderService) {
return new Stub(itf, downloaderService);
}
/**
* Starts the download if necessary. This function starts a flow that does `
* many things. 1) Checks to see if the APK version has been checked and
* the metadata database updated 2) If the APK version does not match,
* checks the new LVL status to see if a new download is required 3) If the
* APK version does match, then checks to see if the download(s) have been
* completed 4) If the downloads have been completed, returns
* NO_DOWNLOAD_REQUIRED The idea is that this can be called during the
* startup of an application to quickly ascertain if the application needs
* to wait to hear about any updated APK expansion files. Note that this does
* mean that the application MUST be run for the first time with a network
* connection, even if Market delivers all of the files.
*
* @param context Your application Context.
* @param notificationClient A PendingIntent to start the Activity in your application
* that shows the download progress and which will also start the application when download
* completes.
* @param serviceClass the class of your {@link imp.DownloaderService} implementation
* @return whether the service was started and the reason for starting the service.
* Either {@link #NO_DOWNLOAD_REQUIRED}, {@link #LVL_CHECK_REQUIRED}, or {@link
* #DOWNLOAD_REQUIRED}.
* @throws NameNotFoundException
*/
public static int startDownloadServiceIfRequired(Context context, PendingIntent notificationClient,
Class<?> serviceClass)
throws NameNotFoundException {
return DownloaderService.startDownloadServiceIfRequired(context, notificationClient,
serviceClass);
}
/**
* This version assumes that the intent contains the pending intent as a parameter. This
* is used for responding to alarms.
* <p>The pending intent must be in an extra with the key {@link
* impl.DownloaderService#EXTRA_PENDING_INTENT}.
*
* @param context
* @param notificationClient
* @param serviceClass the class of the service to start
* @return
* @throws NameNotFoundException
*/
public static int startDownloadServiceIfRequired(Context context, Intent notificationClient,
Class<?> serviceClass)
throws NameNotFoundException {
return DownloaderService.startDownloadServiceIfRequired(context, notificationClient,
serviceClass);
}
}
@@ -1,201 +0,0 @@
/*
* Copyright (C) 2012 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.google.android.vending.expansion.downloader;
import com.google.android.vending.expansion.downloader.impl.DownloaderService;
import android.content.Context;
import android.os.Bundle;
import android.os.Handler;
import android.os.Message;
import android.os.Messenger;
import android.os.RemoteException;
// -- GODOT start --
import java.lang.ref.WeakReference;
// -- GODOT end --
/**
* This class is used by the client activity to proxy requests to the Downloader
* Service.
*
* Most importantly, you must call {@link #CreateProxy} during the {@link
* IDownloaderClient#onServiceConnected} callback in your activity in order to instantiate
* an {@link IDownloaderService} object that you can then use to issue commands to the {@link
* DownloaderService} (such as to pause and resume downloads).
*/
public class DownloaderServiceMarshaller {
public static final int MSG_REQUEST_ABORT_DOWNLOAD =
1;
public static final int MSG_REQUEST_PAUSE_DOWNLOAD =
2;
public static final int MSG_SET_DOWNLOAD_FLAGS =
3;
public static final int MSG_REQUEST_CONTINUE_DOWNLOAD =
4;
public static final int MSG_REQUEST_DOWNLOAD_STATE =
5;
public static final int MSG_REQUEST_CLIENT_UPDATE =
6;
public static final String PARAMS_FLAGS = "flags";
public static final String PARAM_MESSENGER = DownloaderService.EXTRA_MESSAGE_HANDLER;
private static class Proxy implements IDownloaderService {
private Messenger mMsg;
private void send(int method, Bundle params) {
Message m = Message.obtain(null, method);
m.setData(params);
try {
mMsg.send(m);
} catch (RemoteException e) {
e.printStackTrace();
}
}
public Proxy(Messenger msg) {
mMsg = msg;
}
@Override
public void requestAbortDownload() {
send(MSG_REQUEST_ABORT_DOWNLOAD, new Bundle());
}
@Override
public void requestPauseDownload() {
send(MSG_REQUEST_PAUSE_DOWNLOAD, new Bundle());
}
@Override
public void setDownloadFlags(int flags) {
Bundle params = new Bundle();
params.putInt(PARAMS_FLAGS, flags);
send(MSG_SET_DOWNLOAD_FLAGS, params);
}
@Override
public void requestContinueDownload() {
send(MSG_REQUEST_CONTINUE_DOWNLOAD, new Bundle());
}
@Override
public void requestDownloadStatus() {
send(MSG_REQUEST_DOWNLOAD_STATE, new Bundle());
}
@Override
public void onClientUpdated(Messenger clientMessenger) {
Bundle bundle = new Bundle(1);
bundle.putParcelable(PARAM_MESSENGER, clientMessenger);
send(MSG_REQUEST_CLIENT_UPDATE, bundle);
}
}
private static class Stub implements IStub {
private IDownloaderService mItf = null;
// -- GODOT start --
private final MessengerHandlerServer mMsgHandler = new MessengerHandlerServer(this);
final Messenger mMessenger = new Messenger(mMsgHandler);
private static class MessengerHandlerServer extends Handler {
private final WeakReference<Stub> mDownloader;
public MessengerHandlerServer(Stub downloader) {
mDownloader = new WeakReference<>(downloader);
}
@Override
public void handleMessage(Message msg) {
Stub downloader = mDownloader.get();
if (downloader != null) {
downloader.handleMessage(msg);
}
}
}
private void handleMessage(Message msg) {
switch (msg.what) {
case MSG_REQUEST_ABORT_DOWNLOAD:
mItf.requestAbortDownload();
break;
case MSG_REQUEST_CONTINUE_DOWNLOAD:
mItf.requestContinueDownload();
break;
case MSG_REQUEST_PAUSE_DOWNLOAD:
mItf.requestPauseDownload();
break;
case MSG_SET_DOWNLOAD_FLAGS:
mItf.setDownloadFlags(msg.getData().getInt(PARAMS_FLAGS));
break;
case MSG_REQUEST_DOWNLOAD_STATE:
mItf.requestDownloadStatus();
break;
case MSG_REQUEST_CLIENT_UPDATE:
mItf.onClientUpdated((Messenger)msg.getData().getParcelable(
PARAM_MESSENGER));
break;
}
}
// -- GODOT end --
public Stub(IDownloaderService itf) {
mItf = itf;
}
@Override
public Messenger getMessenger() {
return mMessenger;
}
@Override
public void connect(Context c) {
}
@Override
public void disconnect(Context c) {
}
}
/**
* Returns a proxy that will marshall calls to IDownloaderService methods
*
* @param ctx
* @return
*/
public static IDownloaderService CreateProxy(Messenger msg) {
return new Proxy(msg);
}
/**
* Returns a stub object that, when connected, will listen for marshalled
* IDownloaderService methods and translate them into calls to the supplied
* interface.
*
* @param itf An implementation of IDownloaderService that will be called
* when remote method calls are unmarshalled.
* @return
*/
public static IStub CreateStub(IDownloaderService itf) {
return new Stub(itf);
}
}
@@ -1,360 +0,0 @@
/*
* Copyright (C) 2012 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.google.android.vending.expansion.downloader;
import android.annotation.TargetApi;
import android.content.Context;
import android.os.Build;
import android.os.Environment;
import android.os.StatFs;
import android.os.SystemClock;
import android.util.Log;
// -- GODOT start --
//import com.android.vending.expansion.downloader.R;
import org.godotengine.godot.R;
// -- GODOT end --
import java.io.File;
import java.text.SimpleDateFormat;
import java.util.Date;
import java.util.Locale;
import java.util.Random;
import java.util.TimeZone;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* Some helper functions for the download manager
*/
public class Helpers {
public static Random sRandom = new Random(SystemClock.uptimeMillis());
/** Regex used to parse content-disposition headers */
private static final Pattern CONTENT_DISPOSITION_PATTERN = Pattern
.compile("attachment;\\s*filename\\s*=\\s*\"([^\"]*)\"");
private Helpers() {
}
/*
* Parse the Content-Disposition HTTP Header. The format of the header is defined here:
* https://www.w3.org/Protocols/rfc2616/rfc2616-sec19.html This header provides a filename for
* content that is going to be downloaded to the file system. We only support the attachment
* type.
*/
static String parseContentDisposition(String contentDisposition) {
try {
Matcher m = CONTENT_DISPOSITION_PATTERN.matcher(contentDisposition);
if (m.find()) {
return m.group(1);
}
} catch (IllegalStateException ex) {
// This function is defined as returning null when it can't parse
// the header
}
return null;
}
/**
* @return the root of the filesystem containing the given path
*/
public static File getFilesystemRoot(String path) {
File cache = Environment.getDownloadCacheDirectory();
if (path.startsWith(cache.getPath())) {
return cache;
}
File external = Environment.getExternalStorageDirectory();
if (path.startsWith(external.getPath())) {
return external;
}
throw new IllegalArgumentException(
"Cannot determine filesystem root for " + path);
}
public static boolean isExternalMediaMounted() {
if (!Environment.getExternalStorageState().equals(
Environment.MEDIA_MOUNTED)) {
// No SD card found.
if (Constants.LOGVV) {
Log.d(Constants.TAG, "no external storage");
}
return false;
}
return true;
}
/**
* @return the number of bytes available on the filesystem rooted at the given File
*/
public static long getAvailableBytes(File root) {
StatFs stat = new StatFs(root.getPath());
// put a bit of margin (in case creating the file grows the system by a
// few blocks)
long availableBlocks = (long) stat.getAvailableBlocks() - 4;
return stat.getBlockSize() * availableBlocks;
}
/**
* Checks whether the filename looks legitimate
*/
public static boolean isFilenameValid(String filename) {
filename = filename.replaceFirst("/+", "/"); // normalize leading
// slashes
return filename.startsWith(Environment.getDownloadCacheDirectory().toString())
|| filename.startsWith(Environment.getExternalStorageDirectory().toString());
}
/*
* Delete the given file from device
*/
/* package */static void deleteFile(String path) {
try {
File file = new File(path);
file.delete();
} catch (Exception e) {
Log.w(Constants.TAG, "file: '" + path + "' couldn't be deleted", e);
}
}
/**
* Showing progress in MB here. It would be nice to choose the unit (KB, MB, GB) based on total
* file size, but given what we know about the expected ranges of file sizes for APK expansion
* files, it's probably not necessary.
*
* @param overallProgress
* @param overallTotal
* @return
*/
static public String getDownloadProgressString(long overallProgress, long overallTotal) {
if (overallTotal == 0) {
if (Constants.LOGVV) {
Log.e(Constants.TAG, "Notification called when total is zero");
}
return "";
}
// -- GODOT start --
return String.format(Locale.ENGLISH, "%.2f",
(float) overallProgress / (1024.0f * 1024.0f))
+ "MB /" +
String.format(Locale.ENGLISH, "%.2f", (float) overallTotal /
(1024.0f * 1024.0f))
+ "MB";
// -- GODOT end --
}
/**
* Adds a percentile to getDownloadProgressString.
*
* @param overallProgress
* @param overallTotal
* @return
*/
static public String getDownloadProgressStringNotification(long overallProgress,
long overallTotal) {
if (overallTotal == 0) {
if (Constants.LOGVV) {
Log.e(Constants.TAG, "Notification called when total is zero");
}
return "";
}
return getDownloadProgressString(overallProgress, overallTotal) + " (" +
getDownloadProgressPercent(overallProgress, overallTotal) + ")";
}
public static String getDownloadProgressPercent(long overallProgress, long overallTotal) {
if (overallTotal == 0) {
if (Constants.LOGVV) {
Log.e(Constants.TAG, "Notification called when total is zero");
}
return "";
}
return Long.toString(overallProgress * 100 / overallTotal) + "%";
}
public static String getSpeedString(float bytesPerMillisecond) {
// -- GODOT start --
return String.format(Locale.ENGLISH, "%.2f", bytesPerMillisecond * 1000 / 1024);
// -- GODOT end --
}
public static String getTimeRemaining(long durationInMilliseconds) {
SimpleDateFormat sdf;
if (durationInMilliseconds > 1000 * 60 * 60) {
sdf = new SimpleDateFormat("HH:mm", Locale.getDefault());
} else {
sdf = new SimpleDateFormat("mm:ss", Locale.getDefault());
}
return sdf.format(new Date(durationInMilliseconds - TimeZone.getDefault().getRawOffset()));
}
/**
* Returns the file name (without full path) for an Expansion APK file from the given context.
*
* @param c the context
* @param mainFile true for main file, false for patch file
* @param versionCode the version of the file
* @return String the file name of the expansion file
*/
public static String getExpansionAPKFileName(Context c, boolean mainFile, int versionCode) {
return (mainFile ? "main." : "patch.") + versionCode + "." + c.getPackageName() + ".obb";
}
/**
* Returns the filename (where the file should be saved) from info about a download
*/
static public String generateSaveFileName(Context c, String fileName) {
String path = getSaveFilePath(c)
+ File.separator + fileName;
return path;
}
static public String getSaveFilePath(Context c) {
// This technically existed since Honeycomb, but it is critical
// on KitKat and greater versions since it will create the
// directory if needed
return c.getObbDir().toString();
}
/**
* Helper function to ascertain the existence of a file and return true/false appropriately
*
* @param c the app/activity/service context
* @param fileName the name (sans path) of the file to query
* @param fileSize the size that the file must match
* @param deleteFileOnMismatch if the file sizes do not match, delete the file
* @return true if it does exist, false otherwise
*/
static public boolean doesFileExist(Context c, String fileName, long fileSize,
boolean deleteFileOnMismatch) {
// the file may have been delivered by Play --- let's make sure
// it's the size we expect
File fileForNewFile = new File(Helpers.generateSaveFileName(c, fileName));
if (fileForNewFile.exists()) {
if (fileForNewFile.length() == fileSize) {
return true;
}
if (deleteFileOnMismatch) {
// delete the file --- we won't be able to resume
// because we cannot confirm the integrity of the file
fileForNewFile.delete();
}
}
return false;
}
public static final int FS_READABLE = 0;
public static final int FS_DOES_NOT_EXIST = 1;
public static final int FS_CANNOT_READ = 2;
/**
* Helper function to ascertain whether a file can be read.
*
* @param c the app/activity/service context
* @param fileName the name (sans path) of the file to query
* @return true if it does exist, false otherwise
*/
static public int getFileStatus(Context c, String fileName) {
// the file may have been delivered by Play --- let's make sure
// it's the size we expect
File fileForNewFile = new File(Helpers.generateSaveFileName(c, fileName));
int returnValue;
if (fileForNewFile.exists()) {
if (fileForNewFile.canRead()) {
returnValue = FS_READABLE;
} else {
returnValue = FS_CANNOT_READ;
}
} else {
returnValue = FS_DOES_NOT_EXIST;
}
return returnValue;
}
/**
* Helper function to ascertain whether the application has the correct access to the OBB
* directory to allow an OBB file to be written.
*
* @param c the app/activity/service context
* @return true if the application can write an OBB file, false otherwise
*/
static public boolean canWriteOBBFile(Context c) {
String path = getSaveFilePath(c);
File fileForNewFile = new File(path);
boolean canWrite;
if (fileForNewFile.exists()) {
canWrite = fileForNewFile.isDirectory() && fileForNewFile.canWrite();
} else {
canWrite = fileForNewFile.mkdirs();
}
return canWrite;
}
/**
* Converts download states that are returned by the
* {@link IDownloaderClient#onDownloadStateChanged} callback into usable strings. This is useful
* if using the state strings built into the library to display user messages.
*
* @param state One of the STATE_* constants from {@link IDownloaderClient}.
* @return string resource ID for the corresponding string.
*/
static public int getDownloaderStringResourceIDFromState(int state) {
switch (state) {
case IDownloaderClient.STATE_IDLE:
return R.string.state_idle;
case IDownloaderClient.STATE_FETCHING_URL:
return R.string.state_fetching_url;
case IDownloaderClient.STATE_CONNECTING:
return R.string.state_connecting;
case IDownloaderClient.STATE_DOWNLOADING:
return R.string.state_downloading;
case IDownloaderClient.STATE_COMPLETED:
return R.string.state_completed;
case IDownloaderClient.STATE_PAUSED_NETWORK_UNAVAILABLE:
return R.string.state_paused_network_unavailable;
case IDownloaderClient.STATE_PAUSED_BY_REQUEST:
return R.string.state_paused_by_request;
case IDownloaderClient.STATE_PAUSED_WIFI_DISABLED_NEED_CELLULAR_PERMISSION:
return R.string.state_paused_wifi_disabled;
case IDownloaderClient.STATE_PAUSED_NEED_CELLULAR_PERMISSION:
return R.string.state_paused_wifi_unavailable;
case IDownloaderClient.STATE_PAUSED_WIFI_DISABLED:
return R.string.state_paused_wifi_disabled;
case IDownloaderClient.STATE_PAUSED_NEED_WIFI:
return R.string.state_paused_wifi_unavailable;
case IDownloaderClient.STATE_PAUSED_ROAMING:
return R.string.state_paused_roaming;
case IDownloaderClient.STATE_PAUSED_NETWORK_SETUP_FAILURE:
return R.string.state_paused_network_setup_failure;
case IDownloaderClient.STATE_PAUSED_SDCARD_UNAVAILABLE:
return R.string.state_paused_sdcard_unavailable;
case IDownloaderClient.STATE_FAILED_UNLICENSED:
return R.string.state_failed_unlicensed;
case IDownloaderClient.STATE_FAILED_FETCHING_URL:
return R.string.state_failed_fetching_url;
case IDownloaderClient.STATE_FAILED_SDCARD_FULL:
return R.string.state_failed_sdcard_full;
case IDownloaderClient.STATE_FAILED_CANCELED:
return R.string.state_failed_cancelled;
default:
return R.string.state_unknown;
}
}
}
@@ -1,126 +0,0 @@
/*
* Copyright (C) 2012 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.google.android.vending.expansion.downloader;
import android.os.Messenger;
/**
* This interface should be implemented by the client activity for the
* downloader. It is used to pass status from the service to the client.
*/
public interface IDownloaderClient {
static final int STATE_IDLE = 1;
static final int STATE_FETCHING_URL = 2;
static final int STATE_CONNECTING = 3;
static final int STATE_DOWNLOADING = 4;
static final int STATE_COMPLETED = 5;
static final int STATE_PAUSED_NETWORK_UNAVAILABLE = 6;
static final int STATE_PAUSED_BY_REQUEST = 7;
/**
* Both STATE_PAUSED_WIFI_DISABLED_NEED_CELLULAR_PERMISSION and
* STATE_PAUSED_NEED_CELLULAR_PERMISSION imply that Wi-Fi is unavailable and
* cellular permission will restart the service. Wi-Fi disabled means that
* the Wi-Fi manager is returning that Wi-Fi is not enabled, while in the
* other case Wi-Fi is enabled but not available.
*/
static final int STATE_PAUSED_WIFI_DISABLED_NEED_CELLULAR_PERMISSION = 8;
static final int STATE_PAUSED_NEED_CELLULAR_PERMISSION = 9;
/**
* Both STATE_PAUSED_WIFI_DISABLED and STATE_PAUSED_NEED_WIFI imply that
* Wi-Fi is unavailable and cellular permission will NOT restart the
* service. Wi-Fi disabled means that the Wi-Fi manager is returning that
* Wi-Fi is not enabled, while in the other case Wi-Fi is enabled but not
* available.
* <p>
* The service does not return these values. We recommend that app
* developers with very large payloads do not allow these payloads to be
* downloaded over cellular connections.
*/
static final int STATE_PAUSED_WIFI_DISABLED = 10;
static final int STATE_PAUSED_NEED_WIFI = 11;
static final int STATE_PAUSED_ROAMING = 12;
/**
* Scary case. We were on a network that redirected us to another website
* that delivered us the wrong file.
*/
static final int STATE_PAUSED_NETWORK_SETUP_FAILURE = 13;
static final int STATE_PAUSED_SDCARD_UNAVAILABLE = 14;
static final int STATE_FAILED_UNLICENSED = 15;
static final int STATE_FAILED_FETCHING_URL = 16;
static final int STATE_FAILED_SDCARD_FULL = 17;
static final int STATE_FAILED_CANCELED = 18;
static final int STATE_FAILED = 19;
/**
* Called internally by the stub when the service is bound to the client.
* <p>
* Critical implementation detail. In onServiceConnected we create the
* remote service and marshaler. This is how we pass the client information
* back to the service so the client can be properly notified of changes. We
* must do this every time we reconnect to the service.
* <p>
* That is, when you receive this callback, you should call
* {@link DownloaderServiceMarshaller#CreateProxy} to instantiate a member
* instance of {@link IDownloaderService}, then call
* {@link IDownloaderService#onClientUpdated} with the Messenger retrieved
* from your {@link IStub} proxy object.
*
* @param m the service Messenger. This Messenger is used to call the
* service API from the client.
*/
void onServiceConnected(Messenger m);
/**
* Called when the download state changes. Depending on the state, there may
* be user requests. The service is free to change the download state in the
* middle of a user request, so the client should be able to handle this.
* <p>
* The Downloader Library includes a collection of string resources that
* correspond to each of the states, which you can use to provide users a
* useful message based on the state provided in this callback. To fetch the
* appropriate string for a state, call
* {@link Helpers#getDownloaderStringResourceIDFromState}.
* <p>
* What this means to the developer: The application has gotten a message
* that the download has paused due to lack of WiFi. The developer should
* then show UI asking the user if they want to enable downloading over
* cellular connections with appropriate warnings. If the application
* suddenly starts downloading, the application should revert to showing the
* progress again, rather than leaving up the download over cellular UI up.
*
* @param newState one of the STATE_* values defined in IDownloaderClient
*/
void onDownloadStateChanged(int newState);
/**
* Shows the download progress. This is intended to be used to fill out a
* client UI. This progress should only be shown in a few states such as
* STATE_DOWNLOADING.
*
* @param progress the DownloadProgressInfo object containing the current
* progress of all downloads.
*/
void onDownloadProgress(DownloadProgressInfo progress);
}
@@ -1,83 +0,0 @@
/*
* Copyright (C) 2012 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.google.android.vending.expansion.downloader;
import com.google.android.vending.expansion.downloader.impl.DownloaderService;
import android.os.Messenger;
/**
* This interface is implemented by the DownloaderService and by the
* DownloaderServiceMarshaller. It contains functions to control the service.
* When a client binds to the service, it must call the onClientUpdated
* function.
* <p>
* You can acquire a proxy that implements this interface for your service by
* calling {@link DownloaderServiceMarshaller#CreateProxy} during the
* {@link IDownloaderClient#onServiceConnected} callback. At which point, you
* should immediately call {@link #onClientUpdated}.
*/
public interface IDownloaderService {
/**
* Set this flag in response to the
* IDownloaderClient.STATE_PAUSED_NEED_CELLULAR_PERMISSION state and then
* call RequestContinueDownload to resume a download
*/
public static final int FLAGS_DOWNLOAD_OVER_CELLULAR = 1;
/**
* Request that the service abort the current download. The service should
* respond by changing the state to {@link IDownloaderClient.STATE_ABORTED}.
*/
void requestAbortDownload();
/**
* Request that the service pause the current download. The service should
* respond by changing the state to
* {@link IDownloaderClient.STATE_PAUSED_BY_REQUEST}.
*/
void requestPauseDownload();
/**
* Request that the service continue a paused download, when in any paused
* or failed state, including
* {@link IDownloaderClient.STATE_PAUSED_BY_REQUEST}.
*/
void requestContinueDownload();
/**
* Set the flags for this download (e.g.
* {@link DownloaderService.FLAGS_DOWNLOAD_OVER_CELLULAR}).
*
* @param flags
*/
void setDownloadFlags(int flags);
/**
* Requests that the download status be sent to the client.
*/
void requestDownloadStatus();
/**
* Call this when you get {@link
* IDownloaderClient.onServiceConnected(Messenger m)} from the
* DownloaderClient to register the client with the service. It will
* automatically send the current status to the client.
*
* @param clientMessenger
*/
void onClientUpdated(Messenger clientMessenger);
}
@@ -1,41 +0,0 @@
/*
* Copyright (C) 2012 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.google.android.vending.expansion.downloader;
import android.content.Context;
import android.os.Messenger;
/**
* This is the interface that is used to connect/disconnect from the downloader
* service.
* <p>
* You should get a proxy object that implements this interface by calling
* {@link DownloaderClientMarshaller#CreateStub} in your activity when the
* downloader service starts. Then, call {@link #connect} during your activity's
* onResume() and call {@link #disconnect} during onStop().
* <p>
* Then during the {@link IDownloaderClient#onServiceConnected} callback, you
* should call {@link #getMessenger} to pass the stub's Messenger object to
* {@link IDownloaderService#onClientUpdated}.
*/
public interface IStub {
Messenger getMessenger();
void connect(Context c);
void disconnect(Context c);
}
@@ -1,129 +0,0 @@
/*
* Copyright (C) 2012 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.google.android.vending.expansion.downloader;
import android.app.Notification;
import android.app.NotificationManager;
import android.content.Context;
import android.content.Intent;
import android.content.pm.PackageManager.NameNotFoundException;
import android.net.ConnectivityManager;
import android.net.NetworkInfo;
import android.telephony.TelephonyManager;
import android.util.Log;
// -- GODOT start --
import android.annotation.SuppressLint;
// -- GODOT end --
/**
* Contains useful helper functions, typically tied to the application context.
*/
class SystemFacade {
private Context mContext;
private NotificationManager mNotificationManager;
public SystemFacade(Context context) {
mContext = context;
mNotificationManager = (NotificationManager)
mContext.getSystemService(Context.NOTIFICATION_SERVICE);
}
public long currentTimeMillis() {
return System.currentTimeMillis();
}
public Integer getActiveNetworkType() {
ConnectivityManager connectivity =
(ConnectivityManager) mContext.getSystemService(Context.CONNECTIVITY_SERVICE);
if (connectivity == null) {
Log.w(Constants.TAG, "couldn't get connectivity manager");
return null;
}
@SuppressLint("MissingPermission")
NetworkInfo activeInfo = connectivity.getActiveNetworkInfo();
if (activeInfo == null) {
if (Constants.LOGVV) {
Log.v(Constants.TAG, "network is not available");
}
return null;
}
return activeInfo.getType();
}
public boolean isNetworkRoaming() {
ConnectivityManager connectivity =
(ConnectivityManager) mContext.getSystemService(Context.CONNECTIVITY_SERVICE);
if (connectivity == null) {
Log.w(Constants.TAG, "couldn't get connectivity manager");
return false;
}
@SuppressLint("MissingPermission")
NetworkInfo info = connectivity.getActiveNetworkInfo();
boolean isMobile = (info != null && info.getType() == ConnectivityManager.TYPE_MOBILE);
TelephonyManager tm = (TelephonyManager) mContext
.getSystemService(Context.TELEPHONY_SERVICE);
if (null == tm) {
Log.w(Constants.TAG, "couldn't get telephony manager");
return false;
}
boolean isRoaming = isMobile && tm.isNetworkRoaming();
if (Constants.LOGVV && isRoaming) {
Log.v(Constants.TAG, "network is roaming");
}
return isRoaming;
}
public Long getMaxBytesOverMobile() {
return (long) Integer.MAX_VALUE;
}
public Long getRecommendedMaxBytesOverMobile() {
return 2097152L;
}
public void sendBroadcast(Intent intent) {
mContext.sendBroadcast(intent);
}
public boolean userOwnsPackage(int uid, String packageName) throws NameNotFoundException {
return mContext.getPackageManager().getApplicationInfo(packageName, 0).uid == uid;
}
public void postNotification(long id, Notification notification) {
/**
* TODO: The system notification manager takes ints, not longs, as IDs,
* but the download manager uses IDs take straight from the database,
* which are longs. This will have to be dealt with at some point.
*/
mNotificationManager.notify((int) id, notification);
}
public void cancelNotification(long id) {
mNotificationManager.cancel((int) id);
}
public void cancelAllNotifications() {
mNotificationManager.cancelAll();
}
public void startThread(Thread thread) {
thread.start();
}
}
@@ -1,112 +0,0 @@
/*
* Copyright (C) 2012 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.google.android.vending.expansion.downloader.impl;
import android.app.Service;
import android.content.Intent;
import android.os.Handler;
import android.os.HandlerThread;
import android.os.IBinder;
import android.os.Looper;
import android.os.Message;
import android.util.Log;
/**
* This service differs from IntentService in a few minor ways/ It will not
* auto-stop itself after the intent is handled unless the target returns "true"
* in should stop. Since the goal of this service is to handle a single kind of
* intent, it does not queue up batches of intents of the same type.
*/
public abstract class CustomIntentService extends Service {
private String mName;
private boolean mRedelivery;
private volatile ServiceHandler mServiceHandler;
private volatile Looper mServiceLooper;
private static final String LOG_TAG = "CustomIntentService";
private static final int WHAT_MESSAGE = -10;
public CustomIntentService(String paramString) {
this.mName = paramString;
}
@Override
public IBinder onBind(Intent paramIntent) {
return null;
}
@Override
public void onCreate() {
super.onCreate();
HandlerThread localHandlerThread = new HandlerThread("IntentService["
+ this.mName + "]");
localHandlerThread.start();
this.mServiceLooper = localHandlerThread.getLooper();
this.mServiceHandler = new ServiceHandler(this.mServiceLooper);
}
@Override
public void onDestroy() {
Thread localThread = this.mServiceLooper.getThread();
if ((localThread != null) && (localThread.isAlive())) {
localThread.interrupt();
}
this.mServiceLooper.quit();
Log.d(LOG_TAG, "onDestroy");
}
protected abstract void onHandleIntent(Intent paramIntent);
protected abstract boolean shouldStop();
@Override
public void onStart(Intent paramIntent, int startId) {
if (!this.mServiceHandler.hasMessages(WHAT_MESSAGE)) {
Message localMessage = this.mServiceHandler.obtainMessage();
localMessage.arg1 = startId;
localMessage.obj = paramIntent;
localMessage.what = WHAT_MESSAGE;
this.mServiceHandler.sendMessage(localMessage);
}
}
@Override
public int onStartCommand(Intent paramIntent, int flags, int startId) {
onStart(paramIntent, startId);
return mRedelivery ? START_REDELIVER_INTENT : START_NOT_STICKY;
}
public void setIntentRedelivery(boolean enabled) {
this.mRedelivery = enabled;
}
private final class ServiceHandler extends Handler {
public ServiceHandler(Looper looper) {
super(looper);
}
@Override
public void handleMessage(Message paramMessage) {
CustomIntentService.this
.onHandleIntent((Intent) paramMessage.obj);
if (shouldStop()) {
Log.d(LOG_TAG, "stopSelf");
CustomIntentService.this.stopSelf(paramMessage.arg1);
Log.d(LOG_TAG, "afterStopSelf");
}
}
}
}
@@ -1,92 +0,0 @@
/*
* Copyright (C) 2012 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.google.android.vending.expansion.downloader.impl;
import com.google.android.vending.expansion.downloader.Constants;
import com.google.android.vending.expansion.downloader.Helpers;
import android.util.Log;
/**
* Representation of information about an individual download from the database.
*/
public class DownloadInfo {
public String mUri;
public final int mIndex;
public final String mFileName;
public String mETag;
public long mTotalBytes;
public long mCurrentBytes;
public long mLastMod;
public int mStatus;
public int mControl;
public int mNumFailed;
public int mRetryAfter;
public int mRedirectCount;
boolean mInitialized;
public int mFuzz;
public DownloadInfo(int index, String fileName, String pkg) {
mFuzz = Helpers.sRandom.nextInt(1001);
mFileName = fileName;
mIndex = index;
}
public void resetDownload() {
mCurrentBytes = 0;
mETag = "";
mLastMod = 0;
mStatus = 0;
mControl = 0;
mNumFailed = 0;
mRetryAfter = 0;
mRedirectCount = 0;
}
/**
* Returns the time when a download should be restarted.
*/
public long restartTime(long now) {
if (mNumFailed == 0) {
return now;
}
if (mRetryAfter > 0) {
return mLastMod + mRetryAfter;
}
return mLastMod +
Constants.RETRY_FIRST_DELAY *
(1000 + mFuzz) * (1 << (mNumFailed - 1));
}
public void logVerboseInfo() {
Log.v(Constants.TAG, "Service adding new entry");
Log.v(Constants.TAG, "FILENAME: " + mFileName);
Log.v(Constants.TAG, "URI : " + mUri);
Log.v(Constants.TAG, "FILENAME: " + mFileName);
Log.v(Constants.TAG, "CONTROL : " + mControl);
Log.v(Constants.TAG, "STATUS : " + mStatus);
Log.v(Constants.TAG, "FAILED_C: " + mNumFailed);
Log.v(Constants.TAG, "RETRY_AF: " + mRetryAfter);
Log.v(Constants.TAG, "REDIRECT: " + mRedirectCount);
Log.v(Constants.TAG, "LAST_MOD: " + mLastMod);
Log.v(Constants.TAG, "TOTAL : " + mTotalBytes);
Log.v(Constants.TAG, "CURRENT : " + mCurrentBytes);
Log.v(Constants.TAG, "ETAG : " + mETag);
}
}
@@ -1,229 +0,0 @@
/*
* Copyright (C) 2012 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.google.android.vending.expansion.downloader.impl;
// -- GODOT start --
//import com.android.vending.expansion.downloader.R;
import org.godotengine.godot.R;
// -- GODOT end --
import com.google.android.vending.expansion.downloader.DownloadProgressInfo;
import com.google.android.vending.expansion.downloader.DownloaderClientMarshaller;
import com.google.android.vending.expansion.downloader.Helpers;
import com.google.android.vending.expansion.downloader.IDownloaderClient;
import android.app.NotificationManager;
import android.app.PendingIntent;
import android.content.Context;
import android.os.Messenger;
import androidx.core.app.NotificationCompat;
/**
* This class handles displaying the notification associated with the download
* queue going on in the download manager. It handles multiple status types;
* Some require user interaction and some do not. Some of the user interactions
* may be transient. (for example: the user is queried to continue the download
* on 3G when it started on WiFi, but then the phone locks onto WiFi again so
* the prompt automatically goes away)
* <p/>
* The application interface for the downloader also needs to understand and
* handle these transient states.
*/
public class DownloadNotification implements IDownloaderClient {
private int mState;
private final Context mContext;
private final NotificationManager mNotificationManager;
private CharSequence mCurrentTitle;
private IDownloaderClient mClientProxy;
private NotificationCompat.Builder mActiveDownloadBuilder;
private NotificationCompat.Builder mBuilder;
private NotificationCompat.Builder mCurrentBuilder;
private CharSequence mLabel;
private String mCurrentText;
private DownloadProgressInfo mProgressInfo;
private PendingIntent mContentIntent;
static final String LOGTAG = "DownloadNotification";
static final int NOTIFICATION_ID = LOGTAG.hashCode();
public PendingIntent getClientIntent() {
return mContentIntent;
}
public void setClientIntent(PendingIntent clientIntent) {
this.mBuilder.setContentIntent(clientIntent);
this.mActiveDownloadBuilder.setContentIntent(clientIntent);
this.mContentIntent = clientIntent;
}
public void resendState() {
if (null != mClientProxy) {
mClientProxy.onDownloadStateChanged(mState);
}
}
@Override
public void onDownloadStateChanged(int newState) {
if (null != mClientProxy) {
mClientProxy.onDownloadStateChanged(newState);
}
if (newState != mState) {
mState = newState;
if (newState == IDownloaderClient.STATE_IDLE || null == mContentIntent) {
return;
}
int stringDownloadID;
int iconResource;
boolean ongoingEvent;
// get the new title string and paused text
switch (newState) {
case 0:
iconResource = android.R.drawable.stat_sys_warning;
stringDownloadID = R.string.state_unknown;
ongoingEvent = false;
break;
case IDownloaderClient.STATE_DOWNLOADING:
iconResource = android.R.drawable.stat_sys_download;
stringDownloadID = Helpers.getDownloaderStringResourceIDFromState(newState);
ongoingEvent = true;
break;
case IDownloaderClient.STATE_FETCHING_URL:
case IDownloaderClient.STATE_CONNECTING:
iconResource = android.R.drawable.stat_sys_download_done;
stringDownloadID = Helpers.getDownloaderStringResourceIDFromState(newState);
ongoingEvent = true;
break;
case IDownloaderClient.STATE_COMPLETED:
case IDownloaderClient.STATE_PAUSED_BY_REQUEST:
iconResource = android.R.drawable.stat_sys_download_done;
stringDownloadID = Helpers.getDownloaderStringResourceIDFromState(newState);
ongoingEvent = false;
break;
case IDownloaderClient.STATE_FAILED:
case IDownloaderClient.STATE_FAILED_CANCELED:
case IDownloaderClient.STATE_FAILED_FETCHING_URL:
case IDownloaderClient.STATE_FAILED_SDCARD_FULL:
case IDownloaderClient.STATE_FAILED_UNLICENSED:
iconResource = android.R.drawable.stat_sys_warning;
stringDownloadID = Helpers.getDownloaderStringResourceIDFromState(newState);
ongoingEvent = false;
break;
default:
iconResource = android.R.drawable.stat_sys_warning;
stringDownloadID = Helpers.getDownloaderStringResourceIDFromState(newState);
ongoingEvent = true;
break;
}
mCurrentText = mContext.getString(stringDownloadID);
mCurrentTitle = mLabel;
mCurrentBuilder.setTicker(mLabel + ": " + mCurrentText);
mCurrentBuilder.setSmallIcon(iconResource);
mCurrentBuilder.setContentTitle(mCurrentTitle);
mCurrentBuilder.setContentText(mCurrentText);
if (ongoingEvent) {
mCurrentBuilder.setOngoing(true);
} else {
mCurrentBuilder.setOngoing(false);
mCurrentBuilder.setAutoCancel(true);
}
mNotificationManager.notify(NOTIFICATION_ID, mCurrentBuilder.build());
}
}
@Override
public void onDownloadProgress(DownloadProgressInfo progress) {
mProgressInfo = progress;
if (null != mClientProxy) {
mClientProxy.onDownloadProgress(progress);
}
if (progress.mOverallTotal <= 0) {
// we just show the text
mBuilder.setTicker(mCurrentTitle);
mBuilder.setSmallIcon(android.R.drawable.stat_sys_download);
mBuilder.setContentTitle(mCurrentTitle);
mBuilder.setContentText(mCurrentText);
mCurrentBuilder = mBuilder;
} else {
mActiveDownloadBuilder.setProgress((int) progress.mOverallTotal, (int) progress.mOverallProgress, false);
mActiveDownloadBuilder.setContentText(Helpers.getDownloadProgressString(progress.mOverallProgress, progress.mOverallTotal));
mActiveDownloadBuilder.setSmallIcon(android.R.drawable.stat_sys_download);
mActiveDownloadBuilder.setTicker(mLabel + ": " + mCurrentText);
mActiveDownloadBuilder.setContentTitle(mLabel);
mActiveDownloadBuilder.setContentInfo(mContext.getString(R.string.time_remaining_notification,
Helpers.getTimeRemaining(progress.mTimeRemaining)));
mCurrentBuilder = mActiveDownloadBuilder;
}
mNotificationManager.notify(NOTIFICATION_ID, mCurrentBuilder.build());
}
/**
* Called in response to onClientUpdated. Creates a new proxy and notifies
* it of the current state.
*
* @param msg the client Messenger to notify
*/
public void setMessenger(Messenger msg) {
mClientProxy = DownloaderClientMarshaller.CreateProxy(msg);
if (null != mProgressInfo) {
mClientProxy.onDownloadProgress(mProgressInfo);
}
if (mState != -1) {
mClientProxy.onDownloadStateChanged(mState);
}
}
/**
* Constructor
*
* @param ctx The context to use to obtain access to the Notification
* Service
*/
DownloadNotification(Context ctx, CharSequence applicationLabel) {
mState = -1;
mContext = ctx;
mLabel = applicationLabel;
mNotificationManager = (NotificationManager)
mContext.getSystemService(Context.NOTIFICATION_SERVICE);
mActiveDownloadBuilder = new NotificationCompat.Builder(ctx);
mBuilder = new NotificationCompat.Builder(ctx);
// Set Notification category and priorities to something that makes sense for a long
// lived background task.
mActiveDownloadBuilder.setPriority(NotificationCompat.PRIORITY_LOW);
mActiveDownloadBuilder.setCategory(NotificationCompat.CATEGORY_PROGRESS);
mBuilder.setPriority(NotificationCompat.PRIORITY_LOW);
mBuilder.setCategory(NotificationCompat.CATEGORY_PROGRESS);
mCurrentBuilder = mBuilder;
}
@Override
public void onServiceConnected(Messenger m) {
}
}
@@ -1,852 +0,0 @@
/*
* Copyright (C) 2015 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.google.android.vending.expansion.downloader.impl;
import com.google.android.vending.expansion.downloader.Constants;
import com.google.android.vending.expansion.downloader.Helpers;
import com.google.android.vending.expansion.downloader.IDownloaderClient;
import android.content.Context;
import android.os.PowerManager;
import android.os.Process;
import android.util.Log;
import java.io.File;
import java.io.FileNotFoundException;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.SyncFailedException;
import java.net.HttpURLConnection;
import java.net.URL;
import java.util.Locale;
/**
* Runs an actual download
*/
public class DownloadThread {
private Context mContext;
private DownloadInfo mInfo;
private DownloaderService mService;
private final DownloadsDB mDB;
private final DownloadNotification mNotification;
private String mUserAgent;
public DownloadThread(DownloadInfo info, DownloaderService service,
DownloadNotification notification) {
mContext = service;
mInfo = info;
mService = service;
mNotification = notification;
mDB = DownloadsDB.getDB(service);
mUserAgent = "APKXDL (Linux; U; Android " + android.os.Build.VERSION.RELEASE + ";"
+ Locale.getDefault().toString() + "; " + android.os.Build.DEVICE + "/"
+ android.os.Build.ID + ")" +
service.getPackageName();
}
/**
* Returns the default user agent
*/
private String userAgent() {
return mUserAgent;
}
/**
* State for the entire run() method.
*/
private static class State {
public String mFilename;
public FileOutputStream mStream;
public boolean mCountRetry = false;
public int mRetryAfter = 0;
public int mRedirectCount = 0;
public String mNewUri;
public boolean mGotData = false;
public String mRequestUri;
public State(DownloadInfo info, DownloaderService service) {
mRedirectCount = info.mRedirectCount;
mRequestUri = info.mUri;
mFilename = service.generateTempSaveFileName(info.mFileName);
}
}
/**
* State within executeDownload()
*/
private static class InnerState {
public int mBytesSoFar = 0;
public int mBytesThisSession = 0;
public String mHeaderETag;
public boolean mContinuingDownload = false;
public String mHeaderContentLength;
public String mHeaderContentDisposition;
public String mHeaderContentLocation;
public int mBytesNotified = 0;
public long mTimeLastNotification = 0;
}
/**
* Raised from methods called by run() to indicate that the current request
* should be stopped immediately. Note the message passed to this exception
* will be logged and therefore must be guaranteed not to contain any PII,
* meaning it generally can't include any information about the request URI,
* headers, or destination filename.
*/
private class StopRequest extends Throwable {
private static final long serialVersionUID = 6338592678988347973L;
public int mFinalStatus;
public StopRequest(int finalStatus, String message) {
super(message);
mFinalStatus = finalStatus;
}
public StopRequest(int finalStatus, String message, Throwable throwable) {
super(message, throwable);
mFinalStatus = finalStatus;
}
}
/**
* Raised from methods called by executeDownload() to indicate that the
* download should be retried immediately.
*/
private class RetryDownload extends Throwable {
private static final long serialVersionUID = 6196036036517540229L;
}
/**
* Executes the download in a separate thread
*/
public void run() {
Process.setThreadPriority(Process.THREAD_PRIORITY_BACKGROUND);
State state = new State(mInfo, mService);
PowerManager.WakeLock wakeLock = null;
int finalStatus = DownloaderService.STATUS_UNKNOWN_ERROR;
try {
PowerManager pm = (PowerManager) mContext.getSystemService(Context.POWER_SERVICE);
// -- GODOT start --
//wakeLock = pm.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, Constants.TAG);
//wakeLock.acquire();
wakeLock = pm.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "org.godot.game:wakelock");
wakeLock.acquire(20 * 60 * 1000L /*20 minutes*/);
// -- GODOT end --
if (Constants.LOGV) {
Log.v(Constants.TAG, "initiating download for " + mInfo.mFileName);
Log.v(Constants.TAG, " at " + mInfo.mUri);
}
boolean finished = false;
while (!finished) {
if (Constants.LOGV) {
Log.v(Constants.TAG, "initiating download for " + mInfo.mFileName);
Log.v(Constants.TAG, " at " + mInfo.mUri);
}
// Set or unset proxy, which may have changed since last GET
// request.
// setDefaultProxy() supports null as proxy parameter.
URL url = new URL(state.mRequestUri);
HttpURLConnection request = (HttpURLConnection)url.openConnection();
request.setRequestProperty("User-Agent", userAgent());
try {
executeDownload(state, request);
finished = true;
} catch (RetryDownload exc) {
// fall through
} finally {
request.disconnect();
request = null;
}
}
if (Constants.LOGV) {
Log.v(Constants.TAG, "download completed for " + mInfo.mFileName);
Log.v(Constants.TAG, " at " + mInfo.mUri);
}
finalizeDestinationFile(state);
finalStatus = DownloaderService.STATUS_SUCCESS;
} catch (StopRequest error) {
// remove the cause before printing, in case it contains PII
Log.w(Constants.TAG,
"Aborting request for download " + mInfo.mFileName + ": " + error.getMessage());
error.printStackTrace();
finalStatus = error.mFinalStatus;
// fall through to finally block
} catch (Throwable ex) { // sometimes the socket code throws unchecked
// exceptions
Log.w(Constants.TAG, "Exception for " + mInfo.mFileName + ": " + ex);
finalStatus = DownloaderService.STATUS_UNKNOWN_ERROR;
// falls through to the code that reports an error
} finally {
if (wakeLock != null) {
wakeLock.release();
wakeLock = null;
}
cleanupDestination(state, finalStatus);
notifyDownloadCompleted(finalStatus, state.mCountRetry, state.mRetryAfter,
state.mRedirectCount, state.mGotData, state.mFilename);
}
}
/**
* Fully execute a single download request - setup and send the request,
* handle the response, and transfer the data to the destination file.
*/
private void executeDownload(State state, HttpURLConnection request)
throws StopRequest, RetryDownload {
InnerState innerState = new InnerState();
byte data[] = new byte[Constants.BUFFER_SIZE];
checkPausedOrCanceled(state);
setupDestinationFile(state, innerState);
addRequestHeaders(innerState, request);
// check just before sending the request to avoid using an invalid
// connection at all
checkConnectivity(state);
mNotification.onDownloadStateChanged(IDownloaderClient.STATE_CONNECTING);
int responseCode = sendRequest(state, request);
handleExceptionalStatus(state, innerState, request, responseCode);
if (Constants.LOGV) {
Log.v(Constants.TAG, "received response for " + mInfo.mUri);
}
processResponseHeaders(state, innerState, request);
InputStream entityStream = openResponseEntity(state, request);
mNotification.onDownloadStateChanged(IDownloaderClient.STATE_DOWNLOADING);
transferData(state, innerState, data, entityStream);
}
/**
* Check if current connectivity is valid for this request.
*/
private void checkConnectivity(State state) throws StopRequest {
switch (mService.getNetworkAvailabilityState(mDB)) {
case DownloaderService.NETWORK_OK:
return;
case DownloaderService.NETWORK_NO_CONNECTION:
throw new StopRequest(DownloaderService.STATUS_WAITING_FOR_NETWORK,
"waiting for network to return");
case DownloaderService.NETWORK_TYPE_DISALLOWED_BY_REQUESTOR:
throw new StopRequest(
DownloaderService.STATUS_QUEUED_FOR_WIFI_OR_CELLULAR_PERMISSION,
"waiting for wifi or for download over cellular to be authorized");
case DownloaderService.NETWORK_CANNOT_USE_ROAMING:
throw new StopRequest(DownloaderService.STATUS_WAITING_FOR_NETWORK,
"roaming is not allowed");
case DownloaderService.NETWORK_UNUSABLE_DUE_TO_SIZE:
throw new StopRequest(DownloaderService.STATUS_QUEUED_FOR_WIFI, "waiting for wifi");
}
}
/**
* Transfer as much data as possible from the HTTP response to the
* destination file.
*
* @param data buffer to use to read data
* @param entityStream stream for reading the HTTP response entity
*/
private void transferData(State state, InnerState innerState, byte[] data,
InputStream entityStream) throws StopRequest {
for (;;) {
int bytesRead = readFromResponse(state, innerState, data, entityStream);
if (bytesRead == -1) { // success, end of stream already reached
handleEndOfStream(state, innerState);
return;
}
state.mGotData = true;
writeDataToDestination(state, data, bytesRead);
innerState.mBytesSoFar += bytesRead;
innerState.mBytesThisSession += bytesRead;
reportProgress(state, innerState);
checkPausedOrCanceled(state);
}
}
/**
* Called after a successful completion to take any necessary action on the
* downloaded file.
*/
private void finalizeDestinationFile(State state) throws StopRequest {
syncDestination(state);
String tempFilename = state.mFilename;
String finalFilename = Helpers.generateSaveFileName(mService, mInfo.mFileName);
if (!state.mFilename.equals(finalFilename)) {
File startFile = new File(tempFilename);
File destFile = new File(finalFilename);
if (mInfo.mTotalBytes != -1 && mInfo.mCurrentBytes == mInfo.mTotalBytes) {
if (!startFile.renameTo(destFile)) {
throw new StopRequest(DownloaderService.STATUS_FILE_ERROR,
"unable to finalize destination file");
}
} else {
throw new StopRequest(DownloaderService.STATUS_FILE_DELIVERED_INCORRECTLY,
"file delivered with incorrect size. probably due to network not browser configured");
}
}
}
/**
* Called just before the thread finishes, regardless of status, to take any
* necessary action on the downloaded file.
*/
private void cleanupDestination(State state, int finalStatus) {
closeDestination(state);
if (state.mFilename != null && DownloaderService.isStatusError(finalStatus)) {
new File(state.mFilename).delete();
state.mFilename = null;
}
}
/**
* Sync the destination file to storage.
*/
private void syncDestination(State state) {
FileOutputStream downloadedFileStream = null;
try {
downloadedFileStream = new FileOutputStream(state.mFilename, true);
downloadedFileStream.getFD().sync();
} catch (FileNotFoundException ex) {
Log.w(Constants.TAG, "file " + state.mFilename + " not found: " + ex);
} catch (SyncFailedException ex) {
Log.w(Constants.TAG, "file " + state.mFilename + " sync failed: " + ex);
} catch (IOException ex) {
Log.w(Constants.TAG, "IOException trying to sync " + state.mFilename + ": " + ex);
} catch (RuntimeException ex) {
Log.w(Constants.TAG, "exception while syncing file: ", ex);
} finally {
if (downloadedFileStream != null) {
try {
downloadedFileStream.close();
} catch (IOException ex) {
Log.w(Constants.TAG, "IOException while closing synced file: ", ex);
} catch (RuntimeException ex) {
Log.w(Constants.TAG, "exception while closing file: ", ex);
}
}
}
}
/**
* Close the destination output stream.
*/
private void closeDestination(State state) {
try {
// close the file
if (state.mStream != null) {
state.mStream.close();
state.mStream = null;
}
} catch (IOException ex) {
if (Constants.LOGV) {
Log.v(Constants.TAG, "exception when closing the file after download : " + ex);
}
// nothing can really be done if the file can't be closed
}
}
/**
* Check if the download has been paused or canceled, stopping the request
* appropriately if it has been.
*/
private void checkPausedOrCanceled(State state) throws StopRequest {
if (mService.getControl() == DownloaderService.CONTROL_PAUSED) {
int status = mService.getStatus();
switch (status) {
case DownloaderService.STATUS_PAUSED_BY_APP:
throw new StopRequest(mService.getStatus(),
"download paused");
}
}
}
/**
* Report download progress through the database if necessary.
*/
private void reportProgress(State state, InnerState innerState) {
long now = System.currentTimeMillis();
if (innerState.mBytesSoFar - innerState.mBytesNotified
> Constants.MIN_PROGRESS_STEP
&& now - innerState.mTimeLastNotification
> Constants.MIN_PROGRESS_TIME) {
// we store progress updates to the database here
mInfo.mCurrentBytes = innerState.mBytesSoFar;
mDB.updateDownloadCurrentBytes(mInfo);
innerState.mBytesNotified = innerState.mBytesSoFar;
innerState.mTimeLastNotification = now;
long totalBytesSoFar = innerState.mBytesThisSession + mService.mBytesSoFar;
if (Constants.LOGVV) {
Log.v(Constants.TAG, "downloaded " + mInfo.mCurrentBytes + " out of "
+ mInfo.mTotalBytes);
Log.v(Constants.TAG, " total " + totalBytesSoFar + " out of "
+ mService.mTotalLength);
}
mService.notifyUpdateBytes(totalBytesSoFar);
}
}
/**
* Write a data buffer to the destination file.
*
* @param data buffer containing the data to write
* @param bytesRead how many bytes to write from the buffer
*/
private void writeDataToDestination(State state, byte[] data, int bytesRead)
throws StopRequest {
for (;;) {
try {
if (state.mStream == null) {
state.mStream = new FileOutputStream(state.mFilename, true);
}
state.mStream.write(data, 0, bytesRead);
// we close after every write --- this may be too inefficient
closeDestination(state);
return;
} catch (IOException ex) {
if (!Helpers.isExternalMediaMounted()) {
throw new StopRequest(DownloaderService.STATUS_DEVICE_NOT_FOUND_ERROR,
"external media not mounted while writing destination file");
}
long availableBytes =
Helpers.getAvailableBytes(Helpers.getFilesystemRoot(state.mFilename));
if (availableBytes < bytesRead) {
throw new StopRequest(DownloaderService.STATUS_INSUFFICIENT_SPACE_ERROR,
"insufficient space while writing destination file", ex);
}
throw new StopRequest(DownloaderService.STATUS_FILE_ERROR,
"while writing destination file: " + ex.toString(), ex);
}
}
}
/**
* Called when we've reached the end of the HTTP response stream, to update
* the database and check for consistency.
*/
private void handleEndOfStream(State state, InnerState innerState) throws StopRequest {
mInfo.mCurrentBytes = innerState.mBytesSoFar;
// this should always be set from the market
// if ( innerState.mHeaderContentLength == null ) {
// mInfo.mTotalBytes = innerState.mBytesSoFar;
// }
mDB.updateDownload(mInfo);
boolean lengthMismatched = (innerState.mHeaderContentLength != null)
&& (innerState.mBytesSoFar != Integer.parseInt(innerState.mHeaderContentLength));
if (lengthMismatched) {
if (cannotResume(innerState)) {
throw new StopRequest(DownloaderService.STATUS_CANNOT_RESUME,
"mismatched content length");
} else {
throw new StopRequest(getFinalStatusForHttpError(state),
"closed socket before end of file");
}
}
}
private boolean cannotResume(InnerState innerState) {
return innerState.mBytesSoFar > 0 && innerState.mHeaderETag == null;
}
/**
* Read some data from the HTTP response stream, handling I/O errors.
*
* @param data buffer to use to read data
* @param entityStream stream for reading the HTTP response entity
* @return the number of bytes actually read or -1 if the end of the stream
* has been reached
*/
private int readFromResponse(State state, InnerState innerState, byte[] data,
InputStream entityStream) throws StopRequest {
try {
return entityStream.read(data);
} catch (IOException ex) {
logNetworkState();
mInfo.mCurrentBytes = innerState.mBytesSoFar;
mDB.updateDownload(mInfo);
if (cannotResume(innerState)) {
String message = "while reading response: " + ex.toString()
+ ", can't resume interrupted download with no ETag";
throw new StopRequest(DownloaderService.STATUS_CANNOT_RESUME,
message, ex);
} else {
throw new StopRequest(getFinalStatusForHttpError(state),
"while reading response: " + ex.toString(), ex);
}
}
}
/**
* Open a stream for the HTTP response entity, handling I/O errors.
*
* @return an InputStream to read the response entity
*/
private InputStream openResponseEntity(State state, HttpURLConnection response)
throws StopRequest {
try {
return response.getInputStream();
} catch (IOException ex) {
logNetworkState();
throw new StopRequest(getFinalStatusForHttpError(state),
"while getting entity: " + ex.toString(), ex);
}
}
private void logNetworkState() {
if (Constants.LOGX) {
Log.i(Constants.TAG,
"Net "
+ (mService.getNetworkAvailabilityState(mDB) == DownloaderService.NETWORK_OK ? "Up"
: "Down"));
}
}
/**
* Read HTTP response headers and take appropriate action, including setting
* up the destination file and updating the database.
*/
private void processResponseHeaders(State state, InnerState innerState, HttpURLConnection response)
throws StopRequest {
if (innerState.mContinuingDownload) {
// ignore response headers on resume requests
return;
}
readResponseHeaders(state, innerState, response);
try {
state.mFilename = mService.generateSaveFile(mInfo.mFileName, mInfo.mTotalBytes);
} catch (DownloaderService.GenerateSaveFileError exc) {
throw new StopRequest(exc.mStatus, exc.mMessage);
}
try {
state.mStream = new FileOutputStream(state.mFilename);
} catch (FileNotFoundException exc) {
// make sure the directory exists
File pathFile = new File(Helpers.getSaveFilePath(mService));
try {
if (pathFile.mkdirs()) {
state.mStream = new FileOutputStream(state.mFilename);
}
} catch (Exception ex) {
throw new StopRequest(DownloaderService.STATUS_FILE_ERROR,
"while opening destination file: " + exc.toString(), exc);
}
}
if (Constants.LOGV) {
Log.v(Constants.TAG, "writing " + mInfo.mUri + " to " + state.mFilename);
}
updateDatabaseFromHeaders(state, innerState);
// check connectivity again now that we know the total size
checkConnectivity(state);
}
/**
* Update necessary database fields based on values of HTTP response headers
* that have been read.
*/
private void updateDatabaseFromHeaders(State state, InnerState innerState) {
mInfo.mETag = innerState.mHeaderETag;
mDB.updateDownload(mInfo);
}
/**
* Read headers from the HTTP response and store them into local state.
*/
private void readResponseHeaders(State state, InnerState innerState, HttpURLConnection response)
throws StopRequest {
String value = response.getHeaderField("Content-Disposition");
if (value != null) {
innerState.mHeaderContentDisposition = value;
}
value = response.getHeaderField("Content-Location");
if (value != null) {
innerState.mHeaderContentLocation = value;
}
value = response.getHeaderField("ETag");
if (value != null) {
innerState.mHeaderETag = value;
}
String headerTransferEncoding = null;
value = response.getHeaderField("Transfer-Encoding");
if (value != null) {
headerTransferEncoding = value;
}
String headerContentType = null;
value = response.getHeaderField("Content-Type");
if (value != null) {
headerContentType = value;
if (!headerContentType.equals("application/vnd.android.obb")) {
throw new StopRequest(DownloaderService.STATUS_FILE_DELIVERED_INCORRECTLY,
"file delivered with incorrect Mime type");
}
}
if (headerTransferEncoding == null) {
long contentLength = response.getContentLength();
if (value != null) {
// this is always set from Market
if (contentLength != -1 && contentLength != mInfo.mTotalBytes) {
// we're most likely on a bad wifi connection -- we should
// probably
// also look at the mime type --- but the size mismatch is
// enough
// to tell us that something is wrong here
Log.e(Constants.TAG, "Incorrect file size delivered.");
} else {
innerState.mHeaderContentLength = Long.toString(contentLength);
}
}
} else {
// Ignore content-length with transfer-encoding - 2616 4.4 3
if (Constants.LOGVV) {
Log.v(Constants.TAG,
"ignoring content-length because of xfer-encoding");
}
}
if (Constants.LOGVV) {
Log.v(Constants.TAG, "Content-Disposition: " +
innerState.mHeaderContentDisposition);
Log.v(Constants.TAG, "Content-Length: " + innerState.mHeaderContentLength);
Log.v(Constants.TAG, "Content-Location: " + innerState.mHeaderContentLocation);
Log.v(Constants.TAG, "ETag: " + innerState.mHeaderETag);
Log.v(Constants.TAG, "Transfer-Encoding: " + headerTransferEncoding);
}
boolean noSizeInfo = innerState.mHeaderContentLength == null
&& (headerTransferEncoding == null
|| !headerTransferEncoding.equalsIgnoreCase("chunked"));
if (noSizeInfo) {
throw new StopRequest(DownloaderService.STATUS_HTTP_DATA_ERROR,
"can't know size of download, giving up");
}
}
/**
* Check the HTTP response status and handle anything unusual (e.g. not
* 200/206).
*/
private void handleExceptionalStatus(State state, InnerState innerState, HttpURLConnection connection, int responseCode)
throws StopRequest, RetryDownload {
if (responseCode == 503 && mInfo.mNumFailed < Constants.MAX_RETRIES) {
handleServiceUnavailable(state, connection);
}
int expectedStatus = innerState.mContinuingDownload ? 206
: DownloaderService.STATUS_SUCCESS;
if (responseCode != expectedStatus) {
handleOtherStatus(state, innerState, responseCode);
} else {
// no longer redirected
state.mRedirectCount = 0;
}
}
/**
* Handle a status that we don't know how to deal with properly.
*/
private void handleOtherStatus(State state, InnerState innerState, int statusCode)
throws StopRequest {
int finalStatus;
if (DownloaderService.isStatusError(statusCode)) {
finalStatus = statusCode;
} else if (statusCode >= 300 && statusCode < 400) {
finalStatus = DownloaderService.STATUS_UNHANDLED_REDIRECT;
} else if (innerState.mContinuingDownload && statusCode == DownloaderService.STATUS_SUCCESS) {
finalStatus = DownloaderService.STATUS_CANNOT_RESUME;
} else {
finalStatus = DownloaderService.STATUS_UNHANDLED_HTTP_CODE;
}
throw new StopRequest(finalStatus, "http error " + statusCode);
}
/**
* Add headers for this download to the HTTP request to allow for resume.
*/
private void addRequestHeaders(InnerState innerState, HttpURLConnection request) {
if (innerState.mContinuingDownload) {
if (innerState.mHeaderETag != null) {
request.setRequestProperty("If-Match", innerState.mHeaderETag);
}
request.setRequestProperty("Range", "bytes=" + innerState.mBytesSoFar + "-");
}
}
/**
* Handle a 503 Service Unavailable status by processing the Retry-After
* header.
*/
private void handleServiceUnavailable(State state, HttpURLConnection connection) throws StopRequest {
if (Constants.LOGVV) {
Log.v(Constants.TAG, "got HTTP response code 503");
}
state.mCountRetry = true;
String retryAfterValue = connection.getHeaderField("Retry-After");
if (retryAfterValue != null) {
try {
if (Constants.LOGVV) {
Log.v(Constants.TAG, "Retry-After :" + retryAfterValue);
}
state.mRetryAfter = Integer.parseInt(retryAfterValue);
if (state.mRetryAfter < 0) {
state.mRetryAfter = 0;
} else {
if (state.mRetryAfter < Constants.MIN_RETRY_AFTER) {
state.mRetryAfter = Constants.MIN_RETRY_AFTER;
} else if (state.mRetryAfter > Constants.MAX_RETRY_AFTER) {
state.mRetryAfter = Constants.MAX_RETRY_AFTER;
}
state.mRetryAfter += Helpers.sRandom.nextInt(Constants.MIN_RETRY_AFTER + 1);
state.mRetryAfter *= 1000;
}
} catch (NumberFormatException ex) {
// ignored - retryAfter stays 0 in this case.
}
}
throw new StopRequest(DownloaderService.STATUS_WAITING_TO_RETRY,
"got 503 Service Unavailable, will retry later");
}
/**
* Send the request to the server, handling any I/O exceptions.
*/
private int sendRequest(State state, HttpURLConnection request)
throws StopRequest {
try {
return request.getResponseCode();
} catch (IllegalArgumentException ex) {
throw new StopRequest(DownloaderService.STATUS_HTTP_DATA_ERROR,
"while trying to execute request: " + ex.toString(), ex);
} catch (IOException ex) {
logNetworkState();
throw new StopRequest(getFinalStatusForHttpError(state),
"while trying to execute request: " + ex.toString(), ex);
}
}
private int getFinalStatusForHttpError(State state) {
if (mService.getNetworkAvailabilityState(mDB) != DownloaderService.NETWORK_OK) {
return DownloaderService.STATUS_WAITING_FOR_NETWORK;
} else if (mInfo.mNumFailed < Constants.MAX_RETRIES) {
state.mCountRetry = true;
return DownloaderService.STATUS_WAITING_TO_RETRY;
} else {
Log.w(Constants.TAG, "reached max retries for " + mInfo.mNumFailed);
return DownloaderService.STATUS_HTTP_DATA_ERROR;
}
}
/**
* Prepare the destination file to receive data. If the file already exists,
* we'll set up appropriately for resumption.
*/
private void setupDestinationFile(State state, InnerState innerState)
throws StopRequest {
if (state.mFilename != null) { // only true if we've already run a
// thread for this download
if (!Helpers.isFilenameValid(state.mFilename)) {
// this should never happen
throw new StopRequest(DownloaderService.STATUS_FILE_ERROR,
"found invalid internal destination filename");
}
// We're resuming a download that got interrupted
File f = new File(state.mFilename);
if (f.exists()) {
long fileLength = f.length();
if (fileLength == 0) {
// The download hadn't actually started, we can restart from
// scratch
f.delete();
state.mFilename = null;
} else if (mInfo.mETag == null) {
// This should've been caught upon failure
f.delete();
throw new StopRequest(DownloaderService.STATUS_CANNOT_RESUME,
"Trying to resume a download that can't be resumed");
} else {
// All right, we'll be able to resume this download
try {
state.mStream = new FileOutputStream(state.mFilename, true);
} catch (FileNotFoundException exc) {
throw new StopRequest(DownloaderService.STATUS_FILE_ERROR,
"while opening destination for resuming: " + exc.toString(), exc);
}
innerState.mBytesSoFar = (int) fileLength;
if (mInfo.mTotalBytes != -1) {
innerState.mHeaderContentLength = Long.toString(mInfo.mTotalBytes);
}
innerState.mHeaderETag = mInfo.mETag;
innerState.mContinuingDownload = true;
}
}
}
if (state.mStream != null) {
closeDestination(state);
}
}
/**
* Stores information about the completed download, and notifies the
* initiating application.
*/
private void notifyDownloadCompleted(
int status, boolean countRetry, int retryAfter, int redirectCount, boolean gotData,
String filename) {
updateDownloadDatabase(
status, countRetry, retryAfter, redirectCount, gotData, filename);
if (DownloaderService.isStatusCompleted(status)) {
// TBD: send status update?
}
}
private void updateDownloadDatabase(
int status, boolean countRetry, int retryAfter, int redirectCount, boolean gotData,
String filename) {
mInfo.mStatus = status;
mInfo.mRetryAfter = retryAfter;
mInfo.mRedirectCount = redirectCount;
mInfo.mLastMod = System.currentTimeMillis();
if (!countRetry) {
mInfo.mNumFailed = 0;
} else if (gotData) {
mInfo.mNumFailed = 1;
} else {
mInfo.mNumFailed++;
}
mDB.updateDownload(mInfo);
}
}
@@ -1,510 +0,0 @@
/*
* Copyright (C) 2012 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.google.android.vending.expansion.downloader.impl;
import android.content.ContentValues;
import android.content.Context;
import android.database.Cursor;
import android.database.sqlite.SQLiteDatabase;
import android.database.sqlite.SQLiteDoneException;
import android.database.sqlite.SQLiteOpenHelper;
import android.database.sqlite.SQLiteStatement;
import android.provider.BaseColumns;
import android.util.Log;
public class DownloadsDB {
private static final String DATABASE_NAME = "DownloadsDB";
private static final int DATABASE_VERSION = 7;
public static final String LOG_TAG = DownloadsDB.class.getName();
final SQLiteOpenHelper mHelper;
SQLiteStatement mGetDownloadByIndex;
SQLiteStatement mUpdateCurrentBytes;
private static DownloadsDB mDownloadsDB;
long mMetadataRowID = -1;
int mVersionCode = -1;
int mStatus = -1;
int mFlags;
static public synchronized DownloadsDB getDB(Context paramContext) {
if (null == mDownloadsDB) {
return new DownloadsDB(paramContext);
}
return mDownloadsDB;
}
private SQLiteStatement getDownloadByIndexStatement() {
if (null == mGetDownloadByIndex) {
mGetDownloadByIndex = mHelper.getReadableDatabase().compileStatement(
"SELECT " + BaseColumns._ID + " FROM "
+ DownloadColumns.TABLE_NAME + " WHERE "
+ DownloadColumns.INDEX + " = ?");
}
return mGetDownloadByIndex;
}
private SQLiteStatement getUpdateCurrentBytesStatement() {
if (null == mUpdateCurrentBytes) {
mUpdateCurrentBytes = mHelper.getReadableDatabase().compileStatement(
"UPDATE " + DownloadColumns.TABLE_NAME + " SET " + DownloadColumns.CURRENTBYTES
+ " = ?" +
" WHERE " + DownloadColumns.INDEX + " = ?");
}
return mUpdateCurrentBytes;
}
private DownloadsDB(Context paramContext) {
this.mHelper = new DownloadsContentDBHelper(paramContext);
final SQLiteDatabase sqldb = mHelper.getReadableDatabase();
// Query for the version code, the row ID of the metadata (for future
// updating) the status and the flags
Cursor cur = sqldb.rawQuery("SELECT " +
MetadataColumns.APKVERSION + "," +
BaseColumns._ID + "," +
MetadataColumns.DOWNLOAD_STATUS + "," +
MetadataColumns.FLAGS +
" FROM "
+ MetadataColumns.TABLE_NAME + " LIMIT 1", null);
if (null != cur && cur.moveToFirst()) {
mVersionCode = cur.getInt(0);
mMetadataRowID = cur.getLong(1);
mStatus = cur.getInt(2);
mFlags = cur.getInt(3);
cur.close();
}
mDownloadsDB = this;
}
protected DownloadInfo getDownloadInfoByFileName(String fileName) {
final SQLiteDatabase sqldb = mHelper.getReadableDatabase();
Cursor itemcur = null;
try {
itemcur = sqldb.query(DownloadColumns.TABLE_NAME, DC_PROJECTION,
DownloadColumns.FILENAME + " = ?",
new String[] {
fileName
}, null, null, null);
if (null != itemcur && itemcur.moveToFirst()) {
return getDownloadInfoFromCursor(itemcur);
}
} finally {
if (null != itemcur)
itemcur.close();
}
return null;
}
public long getIDForDownloadInfo(final DownloadInfo di) {
return getIDByIndex(di.mIndex);
}
public long getIDByIndex(int index) {
SQLiteStatement downloadByIndex = getDownloadByIndexStatement();
downloadByIndex.clearBindings();
downloadByIndex.bindLong(1, index);
try {
return downloadByIndex.simpleQueryForLong();
} catch (SQLiteDoneException e) {
return -1;
}
}
public void updateDownloadCurrentBytes(final DownloadInfo di) {
SQLiteStatement downloadCurrentBytes = getUpdateCurrentBytesStatement();
downloadCurrentBytes.clearBindings();
downloadCurrentBytes.bindLong(1, di.mCurrentBytes);
downloadCurrentBytes.bindLong(2, di.mIndex);
downloadCurrentBytes.execute();
}
public void close() {
this.mHelper.close();
}
protected static class DownloadsContentDBHelper extends SQLiteOpenHelper {
DownloadsContentDBHelper(Context paramContext) {
super(paramContext, DATABASE_NAME, null, DATABASE_VERSION);
}
private String createTableQueryFromArray(String paramString,
String[][] paramArrayOfString) {
StringBuilder localStringBuilder = new StringBuilder();
localStringBuilder.append("CREATE TABLE ");
localStringBuilder.append(paramString);
localStringBuilder.append(" (");
int i = paramArrayOfString.length;
for (int j = 0;; j++) {
if (j >= i) {
localStringBuilder
.setLength(localStringBuilder.length() - 1);
localStringBuilder.append(");");
return localStringBuilder.toString();
}
String[] arrayOfString = paramArrayOfString[j];
localStringBuilder.append(' ');
localStringBuilder.append(arrayOfString[0]);
localStringBuilder.append(' ');
localStringBuilder.append(arrayOfString[1]);
localStringBuilder.append(',');
}
}
/**
* These two arrays must match and have the same order. For every Schema
* there must be a corresponding table name.
*/
static final private String[][][] sSchemas = {
DownloadColumns.SCHEMA, MetadataColumns.SCHEMA
};
static final private String[] sTables = {
DownloadColumns.TABLE_NAME, MetadataColumns.TABLE_NAME
};
/**
* Goes through all of the tables in sTables and drops each table if it
* exists. Altered to no longer make use of reflection.
*/
private void dropTables(SQLiteDatabase paramSQLiteDatabase) {
for (String table : sTables) {
try {
paramSQLiteDatabase.execSQL("DROP TABLE IF EXISTS " + table);
} catch (Exception localException) {
localException.printStackTrace();
}
}
}
/**
* Goes through all of the tables in sTables and creates a database with
* the corresponding schema described in sSchemas. Altered to no longer
* make use of reflection.
*/
public void onCreate(SQLiteDatabase paramSQLiteDatabase) {
int numSchemas = sSchemas.length;
for (int i = 0; i < numSchemas; i++) {
try {
String[][] schema = (String[][]) sSchemas[i];
paramSQLiteDatabase.execSQL(createTableQueryFromArray(
sTables[i], schema));
} catch (Exception localException) {
while (true)
localException.printStackTrace();
}
}
}
public void onUpgrade(SQLiteDatabase paramSQLiteDatabase,
int paramInt1, int paramInt2) {
Log.w(DownloadsContentDBHelper.class.getName(),
"Upgrading database from version " + paramInt1 + " to "
+ paramInt2 + ", which will destroy all old data");
dropTables(paramSQLiteDatabase);
onCreate(paramSQLiteDatabase);
}
}
public static class MetadataColumns implements BaseColumns {
public static final String APKVERSION = "APKVERSION";
public static final String DOWNLOAD_STATUS = "DOWNLOADSTATUS";
public static final String FLAGS = "DOWNLOADFLAGS";
public static final String[][] SCHEMA = {
{
BaseColumns._ID, "INTEGER PRIMARY KEY"
},
{
APKVERSION, "INTEGER"
}, {
DOWNLOAD_STATUS, "INTEGER"
},
{
FLAGS, "INTEGER"
}
};
public static final String TABLE_NAME = "MetadataColumns";
public static final String _ID = "MetadataColumns._id";
}
public static class DownloadColumns implements BaseColumns {
public static final String INDEX = "FILEIDX";
public static final String URI = "URI";
public static final String FILENAME = "FN";
public static final String ETAG = "ETAG";
public static final String TOTALBYTES = "TOTALBYTES";
public static final String CURRENTBYTES = "CURRENTBYTES";
public static final String LASTMOD = "LASTMOD";
public static final String STATUS = "STATUS";
public static final String CONTROL = "CONTROL";
public static final String NUM_FAILED = "FAILCOUNT";
public static final String RETRY_AFTER = "RETRYAFTER";
public static final String REDIRECT_COUNT = "REDIRECTCOUNT";
public static final String[][] SCHEMA = {
{
BaseColumns._ID, "INTEGER PRIMARY KEY"
},
{
INDEX, "INTEGER UNIQUE"
}, {
URI, "TEXT"
},
{
FILENAME, "TEXT UNIQUE"
}, {
ETAG, "TEXT"
},
{
TOTALBYTES, "INTEGER"
}, {
CURRENTBYTES, "INTEGER"
},
{
LASTMOD, "INTEGER"
}, {
STATUS, "INTEGER"
},
{
CONTROL, "INTEGER"
}, {
NUM_FAILED, "INTEGER"
},
{
RETRY_AFTER, "INTEGER"
}, {
REDIRECT_COUNT, "INTEGER"
}
};
public static final String TABLE_NAME = "DownloadColumns";
public static final String _ID = "DownloadColumns._id";
}
private static final String[] DC_PROJECTION = {
DownloadColumns.FILENAME,
DownloadColumns.URI, DownloadColumns.ETAG,
DownloadColumns.TOTALBYTES, DownloadColumns.CURRENTBYTES,
DownloadColumns.LASTMOD, DownloadColumns.STATUS,
DownloadColumns.CONTROL, DownloadColumns.NUM_FAILED,
DownloadColumns.RETRY_AFTER, DownloadColumns.REDIRECT_COUNT,
DownloadColumns.INDEX
};
private static final int FILENAME_IDX = 0;
private static final int URI_IDX = 1;
private static final int ETAG_IDX = 2;
private static final int TOTALBYTES_IDX = 3;
private static final int CURRENTBYTES_IDX = 4;
private static final int LASTMOD_IDX = 5;
private static final int STATUS_IDX = 6;
private static final int CONTROL_IDX = 7;
private static final int NUM_FAILED_IDX = 8;
private static final int RETRY_AFTER_IDX = 9;
private static final int REDIRECT_COUNT_IDX = 10;
private static final int INDEX_IDX = 11;
/**
* This function will add a new file to the database if it does not exist.
*
* @param di DownloadInfo that we wish to store
* @return the row id of the record to be updated/inserted, or -1
*/
public boolean updateDownload(DownloadInfo di) {
ContentValues cv = new ContentValues();
cv.put(DownloadColumns.INDEX, di.mIndex);
cv.put(DownloadColumns.FILENAME, di.mFileName);
cv.put(DownloadColumns.URI, di.mUri);
cv.put(DownloadColumns.ETAG, di.mETag);
cv.put(DownloadColumns.TOTALBYTES, di.mTotalBytes);
cv.put(DownloadColumns.CURRENTBYTES, di.mCurrentBytes);
cv.put(DownloadColumns.LASTMOD, di.mLastMod);
cv.put(DownloadColumns.STATUS, di.mStatus);
cv.put(DownloadColumns.CONTROL, di.mControl);
cv.put(DownloadColumns.NUM_FAILED, di.mNumFailed);
cv.put(DownloadColumns.RETRY_AFTER, di.mRetryAfter);
cv.put(DownloadColumns.REDIRECT_COUNT, di.mRedirectCount);
return updateDownload(di, cv);
}
public boolean updateDownload(DownloadInfo di, ContentValues cv) {
long id = di == null ? -1 : getIDForDownloadInfo(di);
try {
final SQLiteDatabase sqldb = mHelper.getWritableDatabase();
if (id != -1) {
if (1 != sqldb.update(DownloadColumns.TABLE_NAME,
cv, DownloadColumns._ID + " = " + id, null)) {
return false;
}
} else {
return -1 != sqldb.insert(DownloadColumns.TABLE_NAME,
DownloadColumns.URI, cv);
}
} catch (android.database.sqlite.SQLiteException ex) {
ex.printStackTrace();
}
return false;
}
public int getLastCheckedVersionCode() {
return mVersionCode;
}
public boolean isDownloadRequired() {
final SQLiteDatabase sqldb = mHelper.getReadableDatabase();
Cursor cur = sqldb.rawQuery("SELECT Count(*) FROM "
+ DownloadColumns.TABLE_NAME + " WHERE "
+ DownloadColumns.STATUS + " <> 0", null);
try {
if (null != cur && cur.moveToFirst()) {
return 0 == cur.getInt(0);
}
} finally {
if (null != cur)
cur.close();
}
return true;
}
public int getFlags() {
return mFlags;
}
public boolean updateFlags(int flags) {
if (mFlags != flags) {
ContentValues cv = new ContentValues();
cv.put(MetadataColumns.FLAGS, flags);
if (updateMetadata(cv)) {
mFlags = flags;
return true;
} else {
return false;
}
} else {
return true;
}
};
public boolean updateStatus(int status) {
if (mStatus != status) {
ContentValues cv = new ContentValues();
cv.put(MetadataColumns.DOWNLOAD_STATUS, status);
if (updateMetadata(cv)) {
mStatus = status;
return true;
} else {
return false;
}
} else {
return true;
}
};
public boolean updateMetadata(ContentValues cv) {
final SQLiteDatabase sqldb = mHelper.getWritableDatabase();
if (-1 == this.mMetadataRowID) {
long newID = sqldb.insert(MetadataColumns.TABLE_NAME,
MetadataColumns.APKVERSION, cv);
if (-1 == newID)
return false;
mMetadataRowID = newID;
} else {
if (0 == sqldb.update(MetadataColumns.TABLE_NAME, cv,
BaseColumns._ID + " = " + mMetadataRowID, null))
return false;
}
return true;
}
public boolean updateMetadata(int apkVersion, int downloadStatus) {
ContentValues cv = new ContentValues();
cv.put(MetadataColumns.APKVERSION, apkVersion);
cv.put(MetadataColumns.DOWNLOAD_STATUS, downloadStatus);
if (updateMetadata(cv)) {
mVersionCode = apkVersion;
mStatus = downloadStatus;
return true;
} else {
return false;
}
};
public boolean updateFromDb(DownloadInfo di) {
final SQLiteDatabase sqldb = mHelper.getReadableDatabase();
Cursor cur = null;
try {
cur = sqldb.query(DownloadColumns.TABLE_NAME, DC_PROJECTION,
DownloadColumns.FILENAME + "= ?",
new String[] {
di.mFileName
}, null, null, null);
if (null != cur && cur.moveToFirst()) {
setDownloadInfoFromCursor(di, cur);
return true;
}
return false;
} finally {
if (null != cur) {
cur.close();
}
}
}
public void setDownloadInfoFromCursor(DownloadInfo di, Cursor cur) {
di.mUri = cur.getString(URI_IDX);
di.mETag = cur.getString(ETAG_IDX);
di.mTotalBytes = cur.getLong(TOTALBYTES_IDX);
di.mCurrentBytes = cur.getLong(CURRENTBYTES_IDX);
di.mLastMod = cur.getLong(LASTMOD_IDX);
di.mStatus = cur.getInt(STATUS_IDX);
di.mControl = cur.getInt(CONTROL_IDX);
di.mNumFailed = cur.getInt(NUM_FAILED_IDX);
di.mRetryAfter = cur.getInt(RETRY_AFTER_IDX);
di.mRedirectCount = cur.getInt(REDIRECT_COUNT_IDX);
}
public DownloadInfo getDownloadInfoFromCursor(Cursor cur) {
DownloadInfo di = new DownloadInfo(cur.getInt(INDEX_IDX),
cur.getString(FILENAME_IDX), this.getClass().getPackage()
.getName());
setDownloadInfoFromCursor(di, cur);
return di;
}
public DownloadInfo[] getDownloads() {
final SQLiteDatabase sqldb = mHelper.getReadableDatabase();
Cursor cur = null;
try {
cur = sqldb.query(DownloadColumns.TABLE_NAME, DC_PROJECTION, null,
null, null, null, null);
if (null != cur && cur.moveToFirst()) {
DownloadInfo[] retInfos = new DownloadInfo[cur.getCount()];
int idx = 0;
do {
DownloadInfo di = getDownloadInfoFromCursor(cur);
retInfos[idx++] = di;
} while (cur.moveToNext());
return retInfos;
}
return null;
} finally {
if (null != cur) {
cur.close();
}
}
}
}
@@ -1,200 +0,0 @@
/*
* Copyright (C) 2012 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.google.android.vending.expansion.downloader.impl;
import android.text.format.Time;
import java.util.Calendar;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* Helper for parsing an HTTP date.
*/
public final class HttpDateTime {
/*
* Regular expression for parsing HTTP-date. Wdy, DD Mon YYYY HH:MM:SS GMT
* RFC 822, updated by RFC 1123 Weekday, DD-Mon-YY HH:MM:SS GMT RFC 850,
* obsoleted by RFC 1036 Wdy Mon DD HH:MM:SS YYYY ANSI C's asctime() format
* with following variations Wdy, DD-Mon-YYYY HH:MM:SS GMT Wdy, (SP)D Mon
* YYYY HH:MM:SS GMT Wdy,DD Mon YYYY HH:MM:SS GMT Wdy, DD-Mon-YY HH:MM:SS
* GMT Wdy, DD Mon YYYY HH:MM:SS -HHMM Wdy, DD Mon YYYY HH:MM:SS Wdy Mon
* (SP)D HH:MM:SS YYYY Wdy Mon DD HH:MM:SS YYYY GMT HH can be H if the first
* digit is zero. Mon can be the full name of the month.
*/
private static final String HTTP_DATE_RFC_REGEXP =
"([0-9]{1,2})[- ]([A-Za-z]{3,9})[- ]([0-9]{2,4})[ ]"
+ "([0-9]{1,2}:[0-9][0-9]:[0-9][0-9])";
private static final String HTTP_DATE_ANSIC_REGEXP =
"[ ]([A-Za-z]{3,9})[ ]+([0-9]{1,2})[ ]"
+ "([0-9]{1,2}:[0-9][0-9]:[0-9][0-9])[ ]([0-9]{2,4})";
/**
* The compiled version of the HTTP-date regular expressions.
*/
private static final Pattern HTTP_DATE_RFC_PATTERN =
Pattern.compile(HTTP_DATE_RFC_REGEXP);
private static final Pattern HTTP_DATE_ANSIC_PATTERN =
Pattern.compile(HTTP_DATE_ANSIC_REGEXP);
private static class TimeOfDay {
TimeOfDay(int h, int m, int s) {
this.hour = h;
this.minute = m;
this.second = s;
}
int hour;
int minute;
int second;
}
public static long parse(String timeString)
throws IllegalArgumentException {
int date = 1;
int month = Calendar.JANUARY;
int year = 1970;
TimeOfDay timeOfDay;
Matcher rfcMatcher = HTTP_DATE_RFC_PATTERN.matcher(timeString);
if (rfcMatcher.find()) {
date = getDate(rfcMatcher.group(1));
month = getMonth(rfcMatcher.group(2));
year = getYear(rfcMatcher.group(3));
timeOfDay = getTime(rfcMatcher.group(4));
} else {
Matcher ansicMatcher = HTTP_DATE_ANSIC_PATTERN.matcher(timeString);
if (ansicMatcher.find()) {
month = getMonth(ansicMatcher.group(1));
date = getDate(ansicMatcher.group(2));
timeOfDay = getTime(ansicMatcher.group(3));
year = getYear(ansicMatcher.group(4));
} else {
throw new IllegalArgumentException();
}
}
// FIXME: Y2038 BUG!
if (year >= 2038) {
year = 2038;
month = Calendar.JANUARY;
date = 1;
}
Time time = new Time(Time.TIMEZONE_UTC);
time.set(timeOfDay.second, timeOfDay.minute, timeOfDay.hour, date,
month, year);
return time.toMillis(false /* use isDst */);
}
private static int getDate(String dateString) {
if (dateString.length() == 2) {
return (dateString.charAt(0) - '0') * 10
+ (dateString.charAt(1) - '0');
} else {
return (dateString.charAt(0) - '0');
}
}
/*
* jan = 9 + 0 + 13 = 22 feb = 5 + 4 + 1 = 10 mar = 12 + 0 + 17 = 29 apr = 0
* + 15 + 17 = 32 may = 12 + 0 + 24 = 36 jun = 9 + 20 + 13 = 42 jul = 9 + 20
* + 11 = 40 aug = 0 + 20 + 6 = 26 sep = 18 + 4 + 15 = 37 oct = 14 + 2 + 19
* = 35 nov = 13 + 14 + 21 = 48 dec = 3 + 4 + 2 = 9
*/
private static int getMonth(String monthString) {
int hash = Character.toLowerCase(monthString.charAt(0)) +
Character.toLowerCase(monthString.charAt(1)) +
Character.toLowerCase(monthString.charAt(2)) - 3 * 'a';
switch (hash) {
case 22:
return Calendar.JANUARY;
case 10:
return Calendar.FEBRUARY;
case 29:
return Calendar.MARCH;
case 32:
return Calendar.APRIL;
case 36:
return Calendar.MAY;
case 42:
return Calendar.JUNE;
case 40:
return Calendar.JULY;
case 26:
return Calendar.AUGUST;
case 37:
return Calendar.SEPTEMBER;
case 35:
return Calendar.OCTOBER;
case 48:
return Calendar.NOVEMBER;
case 9:
return Calendar.DECEMBER;
default:
throw new IllegalArgumentException();
}
}
private static int getYear(String yearString) {
if (yearString.length() == 2) {
int year = (yearString.charAt(0) - '0') * 10
+ (yearString.charAt(1) - '0');
if (year >= 70) {
return year + 1900;
} else {
return year + 2000;
}
} else if (yearString.length() == 3) {
// According to RFC 2822, three digit years should be added to 1900.
int year = (yearString.charAt(0) - '0') * 100
+ (yearString.charAt(1) - '0') * 10
+ (yearString.charAt(2) - '0');
return year + 1900;
} else if (yearString.length() == 4) {
return (yearString.charAt(0) - '0') * 1000
+ (yearString.charAt(1) - '0') * 100
+ (yearString.charAt(2) - '0') * 10
+ (yearString.charAt(3) - '0');
} else {
return 1970;
}
}
private static TimeOfDay getTime(String timeString) {
// HH might be H
int i = 0;
int hour = timeString.charAt(i++) - '0';
if (timeString.charAt(i) != ':')
hour = hour * 10 + (timeString.charAt(i++) - '0');
// Skip ':'
i++;
int minute = (timeString.charAt(i++) - '0') * 10
+ (timeString.charAt(i++) - '0');
// Skip ':'
i++;
int second = (timeString.charAt(i++) - '0') * 10
+ (timeString.charAt(i++) - '0');
return new TimeOfDay(hour, minute, second);
}
}
@@ -1,110 +0,0 @@
/*
* Copyright (C) 2010 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.google.android.vending.licensing;
import com.google.android.vending.licensing.util.Base64;
import com.google.android.vending.licensing.util.Base64DecoderException;
import java.io.UnsupportedEncodingException;
import java.security.GeneralSecurityException;
import java.security.spec.KeySpec;
import javax.crypto.BadPaddingException;
import javax.crypto.Cipher;
import javax.crypto.IllegalBlockSizeException;
import javax.crypto.SecretKey;
import javax.crypto.SecretKeyFactory;
import javax.crypto.spec.IvParameterSpec;
import javax.crypto.spec.PBEKeySpec;
import javax.crypto.spec.SecretKeySpec;
/**
* An Obfuscator that uses AES to encrypt data.
*/
public class AESObfuscator implements Obfuscator {
private static final String UTF8 = "UTF-8";
private static final String KEYGEN_ALGORITHM = "PBEWITHSHAAND256BITAES-CBC-BC";
private static final String CIPHER_ALGORITHM = "AES/CBC/PKCS5Padding";
private static final byte[] IV =
{ 16, 74, 71, -80, 32, 101, -47, 72, 117, -14, 0, -29, 70, 65, -12, 74 };
private static final String header = "com.google.android.vending.licensing.AESObfuscator-1|";
private Cipher mEncryptor;
private Cipher mDecryptor;
/**
* @param salt an array of random bytes to use for each (un)obfuscation
* @param applicationId application identifier, e.g. the package name
* @param deviceId device identifier. Use as many sources as possible to
* create this unique identifier.
*/
public AESObfuscator(byte[] salt, String applicationId, String deviceId) {
try {
SecretKeyFactory factory = SecretKeyFactory.getInstance(KEYGEN_ALGORITHM);
KeySpec keySpec =
new PBEKeySpec((applicationId + deviceId).toCharArray(), salt, 1024, 256);
SecretKey tmp = factory.generateSecret(keySpec);
SecretKey secret = new SecretKeySpec(tmp.getEncoded(), "AES");
mEncryptor = Cipher.getInstance(CIPHER_ALGORITHM);
mEncryptor.init(Cipher.ENCRYPT_MODE, secret, new IvParameterSpec(IV));
mDecryptor = Cipher.getInstance(CIPHER_ALGORITHM);
mDecryptor.init(Cipher.DECRYPT_MODE, secret, new IvParameterSpec(IV));
} catch (GeneralSecurityException e) {
// This can't happen on a compatible Android device.
throw new RuntimeException("Invalid environment", e);
}
}
public String obfuscate(String original, String key) {
if (original == null) {
return null;
}
try {
// Header is appended as an integrity check
return Base64.encode(mEncryptor.doFinal((header + key + original).getBytes(UTF8)));
} catch (UnsupportedEncodingException e) {
throw new RuntimeException("Invalid environment", e);
} catch (GeneralSecurityException e) {
throw new RuntimeException("Invalid environment", e);
}
}
public String unobfuscate(String obfuscated, String key) throws ValidationException {
if (obfuscated == null) {
return null;
}
try {
String result = new String(mDecryptor.doFinal(Base64.decode(obfuscated)), UTF8);
// Check for presence of header. This serves as a final integrity check, for cases
// where the block size is correct during decryption.
int headerIndex = result.indexOf(header+key);
if (headerIndex != 0) {
throw new ValidationException("Header not found (invalid data or key)" + ":" +
obfuscated);
}
return result.substring(header.length()+key.length(), result.length());
} catch (Base64DecoderException e) {
throw new ValidationException(e.getMessage() + ":" + obfuscated);
} catch (IllegalBlockSizeException e) {
throw new ValidationException(e.getMessage() + ":" + obfuscated);
} catch (BadPaddingException e) {
throw new ValidationException(e.getMessage() + ":" + obfuscated);
} catch (UnsupportedEncodingException e) {
throw new RuntimeException("Invalid environment", e);
}
}
}
@@ -1,414 +0,0 @@
package com.google.android.vending.licensing;
/*
* Copyright (C) 2012 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import android.content.Context;
import android.content.SharedPreferences;
import android.util.Log;
import com.google.android.vending.licensing.util.URIQueryDecoder;
import java.net.URI;
import java.net.URISyntaxException;
import java.util.HashMap;
import java.util.Map;
import java.util.Set;
import java.util.Vector;
/**
* Default policy. All policy decisions are based off of response data received
* from the licensing service. Specifically, the licensing server sends the
* following information: response validity period, error retry period,
* error retry count and a URL for restoring app access in unlicensed cases.
* <p>
* These values will vary based on the the way the application is configured in
* the Google Play publishing console, such as whether the application is
* marked as free or is within its refund period, as well as how often an
* application is checking with the licensing service.
* <p>
* Developers who need more fine grained control over their application's
* licensing policy should implement a custom Policy.
*/
public class APKExpansionPolicy implements Policy {
private static final String TAG = "APKExpansionPolicy";
private static final String PREFS_FILE = "com.google.android.vending.licensing.APKExpansionPolicy";
private static final String PREF_LAST_RESPONSE = "lastResponse";
private static final String PREF_VALIDITY_TIMESTAMP = "validityTimestamp";
private static final String PREF_RETRY_UNTIL = "retryUntil";
private static final String PREF_MAX_RETRIES = "maxRetries";
private static final String PREF_RETRY_COUNT = "retryCount";
private static final String PREF_LICENSING_URL = "licensingUrl";
private static final String DEFAULT_VALIDITY_TIMESTAMP = "0";
private static final String DEFAULT_RETRY_UNTIL = "0";
private static final String DEFAULT_MAX_RETRIES = "0";
private static final String DEFAULT_RETRY_COUNT = "0";
private static final long MILLIS_PER_MINUTE = 60 * 1000;
private long mValidityTimestamp;
private long mRetryUntil;
private long mMaxRetries;
private long mRetryCount;
private long mLastResponseTime = 0;
private int mLastResponse;
private String mLicensingUrl;
private PreferenceObfuscator mPreferences;
private Vector<String> mExpansionURLs = new Vector<String>();
private Vector<String> mExpansionFileNames = new Vector<String>();
private Vector<Long> mExpansionFileSizes = new Vector<Long>();
/**
* The design of the protocol supports n files. Currently the market can
* only deliver two files. To accommodate this, we have these two constants,
* but the order is the only relevant thing here.
*/
public static final int MAIN_FILE_URL_INDEX = 0;
public static final int PATCH_FILE_URL_INDEX = 1;
/**
* @param context The context for the current application
* @param obfuscator An obfuscator to be used with preferences.
*/
public APKExpansionPolicy(Context context, Obfuscator obfuscator) {
// Import old values
SharedPreferences sp = context.getSharedPreferences(PREFS_FILE, Context.MODE_PRIVATE);
mPreferences = new PreferenceObfuscator(sp, obfuscator);
mLastResponse = Integer.parseInt(
mPreferences.getString(PREF_LAST_RESPONSE, Integer.toString(Policy.RETRY)));
mValidityTimestamp = Long.parseLong(mPreferences.getString(PREF_VALIDITY_TIMESTAMP,
DEFAULT_VALIDITY_TIMESTAMP));
mRetryUntil = Long.parseLong(mPreferences.getString(PREF_RETRY_UNTIL, DEFAULT_RETRY_UNTIL));
mMaxRetries = Long.parseLong(mPreferences.getString(PREF_MAX_RETRIES, DEFAULT_MAX_RETRIES));
mRetryCount = Long.parseLong(mPreferences.getString(PREF_RETRY_COUNT, DEFAULT_RETRY_COUNT));
mLicensingUrl = mPreferences.getString(PREF_LICENSING_URL, null);
}
/**
* We call this to guarantee that we fetch a fresh policy from the server.
* This is to be used if the URL is invalid.
*/
public void resetPolicy() {
mPreferences.putString(PREF_LAST_RESPONSE, Integer.toString(Policy.RETRY));
setRetryUntil(DEFAULT_RETRY_UNTIL);
setMaxRetries(DEFAULT_MAX_RETRIES);
setRetryCount(Long.parseLong(DEFAULT_RETRY_COUNT));
setValidityTimestamp(DEFAULT_VALIDITY_TIMESTAMP);
mPreferences.commit();
}
/**
* Process a new response from the license server.
* <p>
* This data will be used for computing future policy decisions. The
* following parameters are processed:
* <ul>
* <li>VT: the timestamp that the client should consider the response valid
* until
* <li>GT: the timestamp that the client should ignore retry errors until
* <li>GR: the number of retry errors that the client should ignore
* <li>LU: a deep link URL that can enable access for unlicensed apps (e.g.
* buy app on the Play Store)
* </ul>
*
* @param response the result from validating the server response
* @param rawData the raw server response data
*/
public void processServerResponse(int response,
com.google.android.vending.licensing.ResponseData rawData) {
// Update retry counter
if (response != Policy.RETRY) {
setRetryCount(0);
} else {
setRetryCount(mRetryCount + 1);
}
// Update server policy data
Map<String, String> extras = decodeExtras(rawData);
if (response == Policy.LICENSED) {
mLastResponse = response;
// Reset the licensing URL since it is only applicable for NOT_LICENSED responses.
setLicensingUrl(null);
setValidityTimestamp(Long.toString(System.currentTimeMillis() + MILLIS_PER_MINUTE));
Set<String> keys = extras.keySet();
for (String key : keys) {
if (key.equals("VT")) {
setValidityTimestamp(extras.get(key));
} else if (key.equals("GT")) {
setRetryUntil(extras.get(key));
} else if (key.equals("GR")) {
setMaxRetries(extras.get(key));
} else if (key.startsWith("FILE_URL")) {
int index = Integer.parseInt(key.substring("FILE_URL".length())) - 1;
setExpansionURL(index, extras.get(key));
} else if (key.startsWith("FILE_NAME")) {
int index = Integer.parseInt(key.substring("FILE_NAME".length())) - 1;
setExpansionFileName(index, extras.get(key));
} else if (key.startsWith("FILE_SIZE")) {
int index = Integer.parseInt(key.substring("FILE_SIZE".length())) - 1;
setExpansionFileSize(index, Long.parseLong(extras.get(key)));
}
}
} else if (response == Policy.NOT_LICENSED) {
// Clear out stale retry params
setValidityTimestamp(DEFAULT_VALIDITY_TIMESTAMP);
setRetryUntil(DEFAULT_RETRY_UNTIL);
setMaxRetries(DEFAULT_MAX_RETRIES);
// Update the licensing URL
setLicensingUrl(extras.get("LU"));
}
setLastResponse(response);
mPreferences.commit();
}
/**
* Set the last license response received from the server and add to
* preferences. You must manually call PreferenceObfuscator.commit() to
* commit these changes to disk.
*
* @param l the response
*/
private void setLastResponse(int l) {
mLastResponseTime = System.currentTimeMillis();
mLastResponse = l;
mPreferences.putString(PREF_LAST_RESPONSE, Integer.toString(l));
}
/**
* Set the current retry count and add to preferences. You must manually
* call PreferenceObfuscator.commit() to commit these changes to disk.
*
* @param c the new retry count
*/
private void setRetryCount(long c) {
mRetryCount = c;
mPreferences.putString(PREF_RETRY_COUNT, Long.toString(c));
}
public long getRetryCount() {
return mRetryCount;
}
/**
* Set the last validity timestamp (VT) received from the server and add to
* preferences. You must manually call PreferenceObfuscator.commit() to
* commit these changes to disk.
*
* @param validityTimestamp the VT string received
*/
private void setValidityTimestamp(String validityTimestamp) {
Long lValidityTimestamp;
try {
lValidityTimestamp = Long.parseLong(validityTimestamp);
} catch (NumberFormatException e) {
// No response or not parseable, expire in one minute.
Log.w(TAG, "License validity timestamp (VT) missing, caching for a minute");
lValidityTimestamp = System.currentTimeMillis() + MILLIS_PER_MINUTE;
validityTimestamp = Long.toString(lValidityTimestamp);
}
mValidityTimestamp = lValidityTimestamp;
mPreferences.putString(PREF_VALIDITY_TIMESTAMP, validityTimestamp);
}
public long getValidityTimestamp() {
return mValidityTimestamp;
}
/**
* Set the retry until timestamp (GT) received from the server and add to
* preferences. You must manually call PreferenceObfuscator.commit() to
* commit these changes to disk.
*
* @param retryUntil the GT string received
*/
private void setRetryUntil(String retryUntil) {
Long lRetryUntil;
try {
lRetryUntil = Long.parseLong(retryUntil);
} catch (NumberFormatException e) {
// No response or not parseable, expire immediately
Log.w(TAG, "License retry timestamp (GT) missing, grace period disabled");
retryUntil = "0";
lRetryUntil = 0l;
}
mRetryUntil = lRetryUntil;
mPreferences.putString(PREF_RETRY_UNTIL, retryUntil);
}
public long getRetryUntil() {
return mRetryUntil;
}
/**
* Set the max retries value (GR) as received from the server and add to
* preferences. You must manually call PreferenceObfuscator.commit() to
* commit these changes to disk.
*
* @param maxRetries the GR string received
*/
private void setMaxRetries(String maxRetries) {
Long lMaxRetries;
try {
lMaxRetries = Long.parseLong(maxRetries);
} catch (NumberFormatException e) {
// No response or not parseable, expire immediately
Log.w(TAG, "Licence retry count (GR) missing, grace period disabled");
maxRetries = "0";
lMaxRetries = 0l;
}
mMaxRetries = lMaxRetries;
mPreferences.putString(PREF_MAX_RETRIES, maxRetries);
}
public long getMaxRetries() {
return mMaxRetries;
}
/**
* Set the licensing URL that displays a Play Store UI for the user to regain app access.
*
* @param url the LU string received
*/
private void setLicensingUrl(String url) {
mLicensingUrl = url;
mPreferences.putString(PREF_LICENSING_URL, url);
}
public String getLicensingUrl() {
return mLicensingUrl;
}
/**
* Gets the count of expansion URLs. Since expansionURLs are not committed
* to preferences, this will return zero if there has been no LVL fetch
* in the current session.
*
* @return the number of expansion URLs. (0,1,2)
*/
public int getExpansionURLCount() {
return mExpansionURLs.size();
}
/**
* Gets the expansion URL. Since these URLs are not committed to
* preferences, this will always return null if there has not been an LVL
* fetch in the current session.
*
* @param index the index of the URL to fetch. This value will be either
* MAIN_FILE_URL_INDEX or PATCH_FILE_URL_INDEX
*/
public String getExpansionURL(int index) {
if (index < mExpansionURLs.size()) {
return mExpansionURLs.elementAt(index);
}
return null;
}
/**
* Sets the expansion URL. Expansion URL's are not committed to preferences,
* but are instead intended to be stored when the license response is
* processed by the front-end.
*
* @param index the index of the expansion URL. This value will be either
* MAIN_FILE_URL_INDEX or PATCH_FILE_URL_INDEX
* @param URL the URL to set
*/
public void setExpansionURL(int index, String URL) {
if (index >= mExpansionURLs.size()) {
mExpansionURLs.setSize(index + 1);
}
mExpansionURLs.set(index, URL);
}
public String getExpansionFileName(int index) {
if (index < mExpansionFileNames.size()) {
return mExpansionFileNames.elementAt(index);
}
return null;
}
public void setExpansionFileName(int index, String name) {
if (index >= mExpansionFileNames.size()) {
mExpansionFileNames.setSize(index + 1);
}
mExpansionFileNames.set(index, name);
}
public long getExpansionFileSize(int index) {
if (index < mExpansionFileSizes.size()) {
return mExpansionFileSizes.elementAt(index);
}
return -1;
}
public void setExpansionFileSize(int index, long size) {
if (index >= mExpansionFileSizes.size()) {
mExpansionFileSizes.setSize(index + 1);
}
mExpansionFileSizes.set(index, size);
}
/**
* {@inheritDoc} This implementation allows access if either:<br>
* <ol>
* <li>a LICENSED response was received within the validity period
* <li>a RETRY response was received in the last minute, and we are under
* the RETRY count or in the RETRY period.
* </ol>
*/
public boolean allowAccess() {
long ts = System.currentTimeMillis();
if (mLastResponse == Policy.LICENSED) {
// Check if the LICENSED response occurred within the validity
// timeout.
if (ts <= mValidityTimestamp) {
// Cached LICENSED response is still valid.
return true;
}
} else if (mLastResponse == Policy.RETRY &&
ts < mLastResponseTime + MILLIS_PER_MINUTE) {
// Only allow access if we are within the retry period or we haven't
// used up our
// max retries.
return (ts <= mRetryUntil || mRetryCount <= mMaxRetries);
}
return false;
}
private Map<String, String> decodeExtras(
com.google.android.vending.licensing.ResponseData rawData) {
Map<String, String> results = new HashMap<String, String>();
if (rawData == null) {
return results;
}
try {
URI rawExtras = new URI("?" + rawData.extra);
URIQueryDecoder.DecodeQuery(rawExtras, results);
} catch (URISyntaxException e) {
Log.w(TAG, "Invalid syntax error while decoding extras data from server.");
}
return results;
}
}
@@ -1,47 +0,0 @@
/*
* Copyright (C) 2010 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.google.android.vending.licensing;
/**
* Allows the developer to limit the number of devices using a single license.
* <p>
* The LICENSED response from the server contains a user identifier unique to
* the &lt;application, user&gt; pair. The developer can send this identifier
* to their own server along with some device identifier (a random number
* generated and stored once per application installation,
* {@link android.telephony.TelephonyManager#getDeviceId getDeviceId},
* {@link android.provider.Settings.Secure#ANDROID_ID ANDROID_ID}, etc).
* The more sources used to identify the device, the harder it will be for an
* attacker to spoof.
* <p>
* The server can look at the &lt;application, user, device id&gt; tuple and
* restrict a user's application license to run on at most 10 different devices
* in a week (for example). We recommend not being too restrictive because a
* user might legitimately have multiple devices or be in the process of
* changing phones. This will catch egregious violations of multiple people
* sharing one license.
*/
public interface DeviceLimiter {
/**
* Checks if this device is allowed to use the given user's license.
*
* @param userId the user whose license the server responded with
* @return LICENSED if the device is allowed, NOT_LICENSED if not, RETRY if an error occurs
*/
int isDeviceAllowed(String userId);
}
@@ -1,389 +0,0 @@
/*
* Copyright (C) 2010 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.google.android.vending.licensing;
import android.content.ComponentName;
import android.content.Context;
import android.content.Intent;
import android.content.ServiceConnection;
import android.content.pm.PackageManager.NameNotFoundException;
import android.net.Uri;
import android.os.Handler;
import android.os.HandlerThread;
import android.os.IBinder;
import android.os.RemoteException;
import android.provider.Settings.Secure;
import android.util.Log;
import com.android.vending.licensing.ILicenseResultListener;
import com.android.vending.licensing.ILicensingService;
import com.google.android.vending.licensing.util.Base64;
import com.google.android.vending.licensing.util.Base64DecoderException;
import java.security.KeyFactory;
import java.security.NoSuchAlgorithmException;
import java.security.PublicKey;
import java.security.SecureRandom;
import java.security.spec.InvalidKeySpecException;
import java.security.spec.X509EncodedKeySpec;
import java.util.Date;
import java.util.HashSet;
import java.util.LinkedList;
import java.util.Queue;
import java.util.Set;
/**
* Client library for Google Play license verifications.
* <p>
* The LicenseChecker is configured via a {@link Policy} which contains the logic to determine
* whether a user should have access to the application. For example, the Policy can define a
* threshold for allowable number of server or client failures before the library reports the user
* as not having access.
* <p>
* Must also provide the Base64-encoded RSA public key associated with your developer account. The
* public key is obtainable from the publisher site.
*/
public class LicenseChecker implements ServiceConnection {
private static final String TAG = "LicenseChecker";
private static final String KEY_FACTORY_ALGORITHM = "RSA";
// Timeout value (in milliseconds) for calls to service.
private static final int TIMEOUT_MS = 10 * 1000;
private static final SecureRandom RANDOM = new SecureRandom();
private static final boolean DEBUG_LICENSE_ERROR = false;
private ILicensingService mService;
private PublicKey mPublicKey;
private final Context mContext;
private final Policy mPolicy;
/**
* A handler for running tasks on a background thread. We don't want license processing to block
* the UI thread.
*/
private Handler mHandler;
private final String mPackageName;
private final String mVersionCode;
private final Set<LicenseValidator> mChecksInProgress = new HashSet<LicenseValidator>();
private final Queue<LicenseValidator> mPendingChecks = new LinkedList<LicenseValidator>();
/**
* @param context a Context
* @param policy implementation of Policy
* @param encodedPublicKey Base64-encoded RSA public key
* @throws IllegalArgumentException if encodedPublicKey is invalid
*/
public LicenseChecker(Context context, Policy policy, String encodedPublicKey) {
mContext = context;
mPolicy = policy;
mPublicKey = generatePublicKey(encodedPublicKey);
mPackageName = mContext.getPackageName();
mVersionCode = getVersionCode(context, mPackageName);
HandlerThread handlerThread = new HandlerThread("background thread");
handlerThread.start();
mHandler = new Handler(handlerThread.getLooper());
}
/**
* Generates a PublicKey instance from a string containing the Base64-encoded public key.
*
* @param encodedPublicKey Base64-encoded public key
* @throws IllegalArgumentException if encodedPublicKey is invalid
*/
private static PublicKey generatePublicKey(String encodedPublicKey) {
try {
byte[] decodedKey = Base64.decode(encodedPublicKey);
KeyFactory keyFactory = KeyFactory.getInstance(KEY_FACTORY_ALGORITHM);
return keyFactory.generatePublic(new X509EncodedKeySpec(decodedKey));
} catch (NoSuchAlgorithmException e) {
// This won't happen in an Android-compatible environment.
throw new RuntimeException(e);
} catch (Base64DecoderException e) {
Log.e(TAG, "Could not decode from Base64.");
throw new IllegalArgumentException(e);
} catch (InvalidKeySpecException e) {
Log.e(TAG, "Invalid key specification.");
throw new IllegalArgumentException(e);
}
}
/**
* Checks if the user should have access to the app. Binds the service if necessary.
* <p>
* NOTE: This call uses a trivially obfuscated string (base64-encoded). For best security, we
* recommend obfuscating the string that is passed into bindService using another method of your
* own devising.
* <p>
* source string: "com.android.vending.licensing.ILicensingService"
* <p>
*
* @param callback
*/
public synchronized void checkAccess(LicenseCheckerCallback callback) {
// If we have a valid recent LICENSED response, we can skip asking
// Market.
if (mPolicy.allowAccess()) {
Log.i(TAG, "Using cached license response");
callback.allow(Policy.LICENSED);
} else {
LicenseValidator validator = new LicenseValidator(mPolicy, new NullDeviceLimiter(),
callback, generateNonce(), mPackageName, mVersionCode);
if (mService == null) {
Log.i(TAG, "Binding to licensing service.");
try {
boolean bindResult = mContext
.bindService(
new Intent(
new String(
// Base64 encoded -
// com.android.vending.licensing.ILicensingService
// Consider encoding this in another way in your
// code to improve security
Base64.decode(
"Y29tLmFuZHJvaWQudmVuZGluZy5saWNlbnNpbmcuSUxpY2Vuc2luZ1NlcnZpY2U=")))
// As of Android 5.0, implicit
// Service Intents are no longer
// allowed because it's not
// possible for the user to
// participate in disambiguating
// them. This does mean we break
// compatibility with Android
// Cupcake devices with this
// release, since setPackage was
// added in Donut.
.setPackage(
new String(
// Base64
// encoded -
// com.android.vending
Base64.decode(
"Y29tLmFuZHJvaWQudmVuZGluZw=="))),
this, // ServiceConnection.
Context.BIND_AUTO_CREATE);
if (bindResult) {
mPendingChecks.offer(validator);
} else {
Log.e(TAG, "Could not bind to service.");
handleServiceConnectionError(validator);
}
} catch (SecurityException e) {
callback.applicationError(LicenseCheckerCallback.ERROR_MISSING_PERMISSION);
} catch (Base64DecoderException e) {
e.printStackTrace();
}
} else {
mPendingChecks.offer(validator);
runChecks();
}
}
}
/**
* Triggers the last deep link licensing URL returned from the server, which redirects users to a
* page which enables them to gain access to the app. If no such URL is returned by the server, it
* will go to the details page of the app in the Play Store.
*/
public void followLastLicensingUrl(Context context) {
String licensingUrl = mPolicy.getLicensingUrl();
if (licensingUrl == null) {
licensingUrl = "https://play.google.com/store/apps/details?id=" + context.getPackageName();
}
Intent marketIntent = new Intent(Intent.ACTION_VIEW, Uri.parse(licensingUrl));
context.startActivity(marketIntent);
}
private void runChecks() {
LicenseValidator validator;
while ((validator = mPendingChecks.poll()) != null) {
try {
Log.i(TAG, "Calling checkLicense on service for " + validator.getPackageName());
mService.checkLicense(
validator.getNonce(), validator.getPackageName(),
new ResultListener(validator));
mChecksInProgress.add(validator);
} catch (RemoteException e) {
Log.w(TAG, "RemoteException in checkLicense call.", e);
handleServiceConnectionError(validator);
}
}
}
private synchronized void finishCheck(LicenseValidator validator) {
mChecksInProgress.remove(validator);
if (mChecksInProgress.isEmpty()) {
cleanupService();
}
}
private class ResultListener extends ILicenseResultListener.Stub {
private final LicenseValidator mValidator;
private Runnable mOnTimeout;
public ResultListener(LicenseValidator validator) {
mValidator = validator;
mOnTimeout = new Runnable() {
public void run() {
Log.i(TAG, "Check timed out.");
handleServiceConnectionError(mValidator);
finishCheck(mValidator);
}
};
startTimeout();
}
private static final int ERROR_CONTACTING_SERVER = 0x101;
private static final int ERROR_INVALID_PACKAGE_NAME = 0x102;
private static final int ERROR_NON_MATCHING_UID = 0x103;
// Runs in IPC thread pool. Post it to the Handler, so we can guarantee
// either this or the timeout runs.
public void verifyLicense(final int responseCode, final String signedData,
final String signature) {
mHandler.post(new Runnable() {
public void run() {
Log.i(TAG, "Received response.");
// Make sure it hasn't already timed out.
if (mChecksInProgress.contains(mValidator)) {
clearTimeout();
mValidator.verify(mPublicKey, responseCode, signedData, signature);
finishCheck(mValidator);
}
if (DEBUG_LICENSE_ERROR) {
boolean logResponse;
String stringError = null;
switch (responseCode) {
case ERROR_CONTACTING_SERVER:
logResponse = true;
stringError = "ERROR_CONTACTING_SERVER";
break;
case ERROR_INVALID_PACKAGE_NAME:
logResponse = true;
stringError = "ERROR_INVALID_PACKAGE_NAME";
break;
case ERROR_NON_MATCHING_UID:
logResponse = true;
stringError = "ERROR_NON_MATCHING_UID";
break;
default:
logResponse = false;
}
if (logResponse) {
String android_id = Secure.getString(mContext.getContentResolver(),
Secure.ANDROID_ID);
Date date = new Date();
Log.d(TAG, "Server Failure: " + stringError);
Log.d(TAG, "Android ID: " + android_id);
Log.d(TAG, "Time: " + date.toGMTString());
}
}
}
});
}
private void startTimeout() {
Log.i(TAG, "Start monitoring timeout.");
mHandler.postDelayed(mOnTimeout, TIMEOUT_MS);
}
private void clearTimeout() {
Log.i(TAG, "Clearing timeout.");
mHandler.removeCallbacks(mOnTimeout);
}
}
public synchronized void onServiceConnected(ComponentName name, IBinder service) {
mService = ILicensingService.Stub.asInterface(service);
runChecks();
}
public synchronized void onServiceDisconnected(ComponentName name) {
// Called when the connection with the service has been
// unexpectedly disconnected. That is, Market crashed.
// If there are any checks in progress, the timeouts will handle them.
Log.w(TAG, "Service unexpectedly disconnected.");
mService = null;
}
/**
* Generates policy response for service connection errors, as a result of disconnections or
* timeouts.
*/
private synchronized void handleServiceConnectionError(LicenseValidator validator) {
mPolicy.processServerResponse(Policy.RETRY, null);
if (mPolicy.allowAccess()) {
validator.getCallback().allow(Policy.RETRY);
} else {
validator.getCallback().dontAllow(Policy.RETRY);
}
}
/** Unbinds service if necessary and removes reference to it. */
private void cleanupService() {
if (mService != null) {
try {
mContext.unbindService(this);
} catch (IllegalArgumentException e) {
// Somehow we've already been unbound. This is a non-fatal
// error.
Log.e(TAG, "Unable to unbind from licensing service (already unbound)");
}
mService = null;
}
}
/**
* Inform the library that the context is about to be destroyed, so that any open connections
* can be cleaned up.
* <p>
* Failure to call this method can result in a crash under certain circumstances, such as during
* screen rotation if an Activity requests the license check or when the user exits the
* application.
*/
public synchronized void onDestroy() {
cleanupService();
mHandler.getLooper().quit();
}
/** Generates a nonce (number used once). */
private int generateNonce() {
return RANDOM.nextInt();
}
/**
* Get version code for the application package name.
*
* @param context
* @param packageName application package name
* @return the version code or empty string if package not found
*/
private static String getVersionCode(Context context, String packageName) {
try {
return String.valueOf(
context.getPackageManager().getPackageInfo(packageName, 0).versionCode);
} catch (NameNotFoundException e) {
Log.e(TAG, "Package not found. could not get version code.");
return "";
}
}
}
@@ -1,67 +0,0 @@
/*
* Copyright (C) 2010 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.google.android.vending.licensing;
/**
* Callback for the license checker library.
* <p>
* Upon checking with the Market server and conferring with the {@link Policy},
* the library calls the appropriate callback method to communicate the result.
* <p>
* <b>The callback does not occur in the original checking thread.</b> Your
* application should post to the appropriate handling thread or lock
* accordingly.
* <p>
* The reason that is passed back with allow/dontAllow is the base status handed
* to the policy for allowed/disallowing the license. Policy.RETRY will call
* allow or dontAllow depending on other statistics associated with the policy,
* while in most cases Policy.NOT_LICENSED will call dontAllow and
* Policy.LICENSED will Allow.
*/
public interface LicenseCheckerCallback {
/**
* Allow use. App should proceed as normal.
*
* @param reason Policy.LICENSED or Policy.RETRY typically. (although in
* theory the policy can return Policy.NOT_LICENSED here as well)
*/
public void allow(int reason);
/**
* Don't allow use. App should inform user and take appropriate action.
*
* @param reason Policy.NOT_LICENSED or Policy.RETRY. (although in theory
* the policy can return Policy.LICENSED here as well ---
* perhaps the call to the LVL took too long, for example)
*/
public void dontAllow(int reason);
/** Application error codes. */
public static final int ERROR_INVALID_PACKAGE_NAME = 1;
public static final int ERROR_NON_MATCHING_UID = 2;
public static final int ERROR_NOT_MARKET_MANAGED = 3;
public static final int ERROR_CHECK_IN_PROGRESS = 4;
public static final int ERROR_INVALID_PUBLIC_KEY = 5;
public static final int ERROR_MISSING_PERMISSION = 6;
/**
* Error in application code. Caller did not call or set up license checker
* correctly. Should be considered fatal.
*/
public void applicationError(int errorCode);
}
@@ -1,231 +0,0 @@
/*
* Copyright (C) 2010 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.google.android.vending.licensing;
import com.google.android.vending.licensing.util.Base64;
import com.google.android.vending.licensing.util.Base64DecoderException;
import android.text.TextUtils;
import android.util.Log;
import java.security.InvalidKeyException;
import java.security.NoSuchAlgorithmException;
import java.security.PublicKey;
import java.security.Signature;
import java.security.SignatureException;
/**
* Contains data related to a licensing request and methods to verify
* and process the response.
*/
class LicenseValidator {
private static final String TAG = "LicenseValidator";
// Server response codes.
private static final int LICENSED = 0x0;
private static final int NOT_LICENSED = 0x1;
private static final int LICENSED_OLD_KEY = 0x2;
private static final int ERROR_NOT_MARKET_MANAGED = 0x3;
private static final int ERROR_SERVER_FAILURE = 0x4;
private static final int ERROR_OVER_QUOTA = 0x5;
private static final int ERROR_CONTACTING_SERVER = 0x101;
private static final int ERROR_INVALID_PACKAGE_NAME = 0x102;
private static final int ERROR_NON_MATCHING_UID = 0x103;
private final Policy mPolicy;
private final LicenseCheckerCallback mCallback;
private final int mNonce;
private final String mPackageName;
private final String mVersionCode;
private final DeviceLimiter mDeviceLimiter;
LicenseValidator(Policy policy, DeviceLimiter deviceLimiter, LicenseCheckerCallback callback,
int nonce, String packageName, String versionCode) {
mPolicy = policy;
mDeviceLimiter = deviceLimiter;
mCallback = callback;
mNonce = nonce;
mPackageName = packageName;
mVersionCode = versionCode;
}
public LicenseCheckerCallback getCallback() {
return mCallback;
}
public int getNonce() {
return mNonce;
}
public String getPackageName() {
return mPackageName;
}
private static final String SIGNATURE_ALGORITHM = "SHA1withRSA";
/**
* Verifies the response from server and calls appropriate callback method.
*
* @param publicKey public key associated with the developer account
* @param responseCode server response code
* @param signedData signed data from server
* @param signature server signature
*/
public void verify(PublicKey publicKey, int responseCode, String signedData, String signature) {
String userId = null;
// Skip signature check for unsuccessful requests
ResponseData data = null;
if (responseCode == LICENSED || responseCode == NOT_LICENSED ||
responseCode == LICENSED_OLD_KEY) {
// Verify signature.
try {
if (TextUtils.isEmpty(signedData)) {
Log.e(TAG, "Signature verification failed: signedData is empty. " +
"(Device not signed-in to any Google accounts?)");
handleInvalidResponse();
return;
}
Signature sig = Signature.getInstance(SIGNATURE_ALGORITHM);
sig.initVerify(publicKey);
sig.update(signedData.getBytes());
if (!sig.verify(Base64.decode(signature))) {
Log.e(TAG, "Signature verification failed.");
handleInvalidResponse();
return;
}
} catch (NoSuchAlgorithmException e) {
// This can't happen on an Android compatible device.
throw new RuntimeException(e);
} catch (InvalidKeyException e) {
handleApplicationError(LicenseCheckerCallback.ERROR_INVALID_PUBLIC_KEY);
return;
} catch (SignatureException e) {
throw new RuntimeException(e);
} catch (Base64DecoderException e) {
Log.e(TAG, "Could not Base64-decode signature.");
handleInvalidResponse();
return;
}
// Parse and validate response.
try {
data = ResponseData.parse(signedData);
} catch (IllegalArgumentException e) {
Log.e(TAG, "Could not parse response.");
handleInvalidResponse();
return;
}
if (data.responseCode != responseCode) {
Log.e(TAG, "Response codes don't match.");
handleInvalidResponse();
return;
}
if (data.nonce != mNonce) {
Log.e(TAG, "Nonce doesn't match.");
handleInvalidResponse();
return;
}
if (!data.packageName.equals(mPackageName)) {
Log.e(TAG, "Package name doesn't match.");
handleInvalidResponse();
return;
}
if (!data.versionCode.equals(mVersionCode)) {
Log.e(TAG, "Version codes don't match.");
handleInvalidResponse();
return;
}
// Application-specific user identifier.
userId = data.userId;
if (TextUtils.isEmpty(userId)) {
Log.e(TAG, "User identifier is empty.");
handleInvalidResponse();
return;
}
}
switch (responseCode) {
case LICENSED:
case LICENSED_OLD_KEY:
int limiterResponse = mDeviceLimiter.isDeviceAllowed(userId);
handleResponse(limiterResponse, data);
break;
case NOT_LICENSED:
handleResponse(Policy.NOT_LICENSED, data);
break;
case ERROR_CONTACTING_SERVER:
Log.w(TAG, "Error contacting licensing server.");
handleResponse(Policy.RETRY, data);
break;
case ERROR_SERVER_FAILURE:
Log.w(TAG, "An error has occurred on the licensing server.");
handleResponse(Policy.RETRY, data);
break;
case ERROR_OVER_QUOTA:
Log.w(TAG, "Licensing server is refusing to talk to this device, over quota.");
handleResponse(Policy.RETRY, data);
break;
case ERROR_INVALID_PACKAGE_NAME:
handleApplicationError(LicenseCheckerCallback.ERROR_INVALID_PACKAGE_NAME);
break;
case ERROR_NON_MATCHING_UID:
handleApplicationError(LicenseCheckerCallback.ERROR_NON_MATCHING_UID);
break;
case ERROR_NOT_MARKET_MANAGED:
handleApplicationError(LicenseCheckerCallback.ERROR_NOT_MARKET_MANAGED);
break;
default:
Log.e(TAG, "Unknown response code for license check.");
handleInvalidResponse();
}
}
/**
* Confers with policy and calls appropriate callback method.
*
* @param response
* @param rawData
*/
private void handleResponse(int response, ResponseData rawData) {
// Update policy data and increment retry counter (if needed)
mPolicy.processServerResponse(response, rawData);
// Given everything we know, including cached data, ask the policy if we should grant
// access.
if (mPolicy.allowAccess()) {
mCallback.allow(response);
} else {
mCallback.dontAllow(response);
}
}
private void handleApplicationError(int code) {
mCallback.applicationError(code);
}
private void handleInvalidResponse() {
mCallback.dontAllow(Policy.NOT_LICENSED);
}
}
@@ -1,32 +0,0 @@
/*
* Copyright (C) 2010 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.google.android.vending.licensing;
/**
* A DeviceLimiter that doesn't limit the number of devices that can use a
* given user's license.
* <p>
* Unless you have reason to believe that your application is being pirated
* by multiple users using the same license (signing in to Market as the same
* user), we recommend you use this implementation.
*/
public class NullDeviceLimiter implements DeviceLimiter {
public int isDeviceAllowed(String userId) {
return Policy.LICENSED;
}
}
@@ -1,48 +0,0 @@
/*
* Copyright (C) 2010 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.google.android.vending.licensing;
/**
* Interface used as part of a {@link Policy} to allow application authors to obfuscate
* licensing data that will be stored into a SharedPreferences file.
* <p>
* Any transformation scheme must be reversible. Implementing classes may optionally implement an
* integrity check to further prevent modification to preference data. Implementing classes
* should use device-specific information as a key in the obfuscation algorithm to prevent
* obfuscated preferences from being shared among devices.
*/
public interface Obfuscator {
/**
* Obfuscate a string that is being stored into shared preferences.
*
* @param original The data that is to be obfuscated.
* @param key The key for the data that is to be obfuscated.
* @return A transformed version of the original data.
*/
String obfuscate(String original, String key);
/**
* Undo the transformation applied to data by the obfuscate() method.
*
* @param obfuscated The data that is to be un-obfuscated.
* @param key The key for the data that is to be un-obfuscated.
* @return The original data transformed by the obfuscate() method.
* @throws ValidationException Optionally thrown if a data integrity check fails.
*/
String unobfuscate(String obfuscated, String key) throws ValidationException;
}
@@ -1,65 +0,0 @@
/*
* Copyright (C) 2010 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.google.android.vending.licensing;
/**
* Policy used by {@link LicenseChecker} to determine whether a user should have
* access to the application.
*/
public interface Policy {
/**
* Change these values to make it more difficult for tools to automatically
* strip LVL protection from your APK.
*/
/**
* LICENSED means that the server returned back a valid license response
*/
public static final int LICENSED = 0x0100;
/**
* NOT_LICENSED means that the server returned back a valid license response
* that indicated that the user definitively is not licensed
*/
public static final int NOT_LICENSED = 0x0231;
/**
* RETRY means that the license response was unable to be determined ---
* perhaps as a result of faulty networking
*/
public static final int RETRY = 0x0123;
/**
* Provide results from contact with the license server. Retry counts are
* incremented if the current value of response is RETRY. Results will be
* used for any future policy decisions.
*
* @param response the result from validating the server response
* @param rawData the raw server response data, can be null for RETRY
*/
void processServerResponse(int response, ResponseData rawData);
/**
* Check if the user should be allowed access to the application.
*/
boolean allowAccess();
/**
* Gets the licensing URL returned by the server that can enable access for unlicensed apps (e.g.
* buy app on the Play Store).
*/
String getLicensingUrl();
}
@@ -1,80 +0,0 @@
/*
* Copyright (C) 2010 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.google.android.vending.licensing;
import android.content.SharedPreferences;
import android.util.Log;
/**
* An wrapper for SharedPreferences that transparently performs data obfuscation.
*/
public class PreferenceObfuscator {
private static final String TAG = "PreferenceObfuscator";
private final SharedPreferences mPreferences;
private final Obfuscator mObfuscator;
private SharedPreferences.Editor mEditor;
/**
* Constructor.
*
* @param sp A SharedPreferences instance provided by the system.
* @param o The Obfuscator to use when reading or writing data.
*/
public PreferenceObfuscator(SharedPreferences sp, Obfuscator o) {
mPreferences = sp;
mObfuscator = o;
mEditor = null;
}
public void putString(String key, String value) {
if (mEditor == null) {
mEditor = mPreferences.edit();
// -- GODOT start --
mEditor.apply();
// -- GODOT end --
}
String obfuscatedValue = mObfuscator.obfuscate(value, key);
mEditor.putString(key, obfuscatedValue);
}
public String getString(String key, String defValue) {
String result;
String value = mPreferences.getString(key, null);
if (value != null) {
try {
result = mObfuscator.unobfuscate(value, key);
} catch (ValidationException e) {
// Unable to unobfuscate, data corrupt or tampered
Log.w(TAG, "Validation error while reading preference: " + key);
result = defValue;
}
} else {
// Preference not found
result = defValue;
}
return result;
}
public void commit() {
if (mEditor != null) {
mEditor.commit();
mEditor = null;
}
}
}
@@ -1,81 +0,0 @@
/*
* Copyright (C) 2010 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.google.android.vending.licensing;
import android.text.TextUtils;
import java.util.regex.Pattern;
/**
* ResponseData from licensing server.
*/
public class ResponseData {
public int responseCode;
public int nonce;
public String packageName;
public String versionCode;
public String userId;
public long timestamp;
/** Response-specific data. */
public String extra;
/**
* Parses response string into ResponseData.
*
* @param responseData response data string
* @throws IllegalArgumentException upon parsing error
* @return ResponseData object
*/
public static ResponseData parse(String responseData) {
// Must parse out main response data and response-specific data.
int index = responseData.indexOf(':');
String mainData, extraData;
if (-1 == index) {
mainData = responseData;
extraData = "";
} else {
mainData = responseData.substring(0, index);
extraData = index >= responseData.length() ? "" : responseData.substring(index + 1);
}
String[] fields = TextUtils.split(mainData, Pattern.quote("|"));
if (fields.length < 6) {
throw new IllegalArgumentException("Wrong number of fields.");
}
ResponseData data = new ResponseData();
data.extra = extraData;
data.responseCode = Integer.parseInt(fields[0]);
data.nonce = Integer.parseInt(fields[1]);
data.packageName = fields[2];
data.versionCode = fields[3];
// Application-specific user identifier.
data.userId = fields[4];
data.timestamp = Long.parseLong(fields[5]);
return data;
}
@Override
public String toString() {
return TextUtils.join("|", new Object[] {
responseCode, nonce, packageName, versionCode,
userId, timestamp
});
}
}
@@ -1,300 +0,0 @@
/*
* Copyright (C) 2010 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.google.android.vending.licensing;
import java.net.URI;
import java.net.URISyntaxException;
import java.util.HashMap;
import java.util.Map;
import android.content.Context;
import android.content.SharedPreferences;
import android.util.Log;
import com.google.android.vending.licensing.util.URIQueryDecoder;
/**
* Default policy. All policy decisions are based off of response data received
* from the licensing service. Specifically, the licensing server sends the
* following information: response validity period, error retry period,
* error retry count and a URL for restoring app access in unlicensed cases.
* <p>
* These values will vary based on the the way the application is configured in
* the Google Play publishing console, such as whether the application is
* marked as free or is within its refund period, as well as how often an
* application is checking with the licensing service.
* <p>
* Developers who need more fine grained control over their application's
* licensing policy should implement a custom Policy.
*/
public class ServerManagedPolicy implements Policy {
private static final String TAG = "ServerManagedPolicy";
private static final String PREFS_FILE = "com.google.android.vending.licensing.ServerManagedPolicy";
private static final String PREF_LAST_RESPONSE = "lastResponse";
private static final String PREF_VALIDITY_TIMESTAMP = "validityTimestamp";
private static final String PREF_RETRY_UNTIL = "retryUntil";
private static final String PREF_MAX_RETRIES = "maxRetries";
private static final String PREF_RETRY_COUNT = "retryCount";
private static final String PREF_LICENSING_URL = "licensingUrl";
private static final String DEFAULT_VALIDITY_TIMESTAMP = "0";
private static final String DEFAULT_RETRY_UNTIL = "0";
private static final String DEFAULT_MAX_RETRIES = "0";
private static final String DEFAULT_RETRY_COUNT = "0";
private static final long MILLIS_PER_MINUTE = 60 * 1000;
private long mValidityTimestamp;
private long mRetryUntil;
private long mMaxRetries;
private long mRetryCount;
private long mLastResponseTime = 0;
private int mLastResponse;
private String mLicensingUrl;
private PreferenceObfuscator mPreferences;
/**
* @param context The context for the current application
* @param obfuscator An obfuscator to be used with preferences.
*/
public ServerManagedPolicy(Context context, Obfuscator obfuscator) {
// Import old values
SharedPreferences sp = context.getSharedPreferences(PREFS_FILE, Context.MODE_PRIVATE);
mPreferences = new PreferenceObfuscator(sp, obfuscator);
mLastResponse = Integer.parseInt(
mPreferences.getString(PREF_LAST_RESPONSE, Integer.toString(Policy.RETRY)));
mValidityTimestamp = Long.parseLong(mPreferences.getString(PREF_VALIDITY_TIMESTAMP,
DEFAULT_VALIDITY_TIMESTAMP));
mRetryUntil = Long.parseLong(mPreferences.getString(PREF_RETRY_UNTIL, DEFAULT_RETRY_UNTIL));
mMaxRetries = Long.parseLong(mPreferences.getString(PREF_MAX_RETRIES, DEFAULT_MAX_RETRIES));
mRetryCount = Long.parseLong(mPreferences.getString(PREF_RETRY_COUNT, DEFAULT_RETRY_COUNT));
mLicensingUrl = mPreferences.getString(PREF_LICENSING_URL, null);
}
/**
* Process a new response from the license server.
* <p>
* This data will be used for computing future policy decisions. The
* following parameters are processed:
* <ul>
* <li>VT: the timestamp that the client should consider the response valid
* until
* <li>GT: the timestamp that the client should ignore retry errors until
* <li>GR: the number of retry errors that the client should ignore
* <li>LU: a deep link URL that can enable access for unlicensed apps (e.g.
* buy app on the Play Store)
* </ul>
*
* @param response the result from validating the server response
* @param rawData the raw server response data
*/
public void processServerResponse(int response, ResponseData rawData) {
// Update retry counter
if (response != Policy.RETRY) {
setRetryCount(0);
} else {
setRetryCount(mRetryCount + 1);
}
// Update server policy data
Map<String, String> extras = decodeExtras(rawData);
if (response == Policy.LICENSED) {
mLastResponse = response;
// Reset the licensing URL since it is only applicable for NOT_LICENSED responses.
setLicensingUrl(null);
setValidityTimestamp(extras.get("VT"));
setRetryUntil(extras.get("GT"));
setMaxRetries(extras.get("GR"));
} else if (response == Policy.NOT_LICENSED) {
// Clear out stale retry params
setValidityTimestamp(DEFAULT_VALIDITY_TIMESTAMP);
setRetryUntil(DEFAULT_RETRY_UNTIL);
setMaxRetries(DEFAULT_MAX_RETRIES);
// Update the licensing URL
setLicensingUrl(extras.get("LU"));
}
setLastResponse(response);
mPreferences.commit();
}
/**
* Set the last license response received from the server and add to
* preferences. You must manually call PreferenceObfuscator.commit() to
* commit these changes to disk.
*
* @param l the response
*/
private void setLastResponse(int l) {
mLastResponseTime = System.currentTimeMillis();
mLastResponse = l;
mPreferences.putString(PREF_LAST_RESPONSE, Integer.toString(l));
}
/**
* Set the current retry count and add to preferences. You must manually
* call PreferenceObfuscator.commit() to commit these changes to disk.
*
* @param c the new retry count
*/
private void setRetryCount(long c) {
mRetryCount = c;
mPreferences.putString(PREF_RETRY_COUNT, Long.toString(c));
}
public long getRetryCount() {
return mRetryCount;
}
/**
* Set the last validity timestamp (VT) received from the server and add to
* preferences. You must manually call PreferenceObfuscator.commit() to
* commit these changes to disk.
*
* @param validityTimestamp the VT string received
*/
private void setValidityTimestamp(String validityTimestamp) {
Long lValidityTimestamp;
try {
lValidityTimestamp = Long.parseLong(validityTimestamp);
} catch (NumberFormatException e) {
// No response or not parsable, expire in one minute.
Log.w(TAG, "License validity timestamp (VT) missing, caching for a minute");
lValidityTimestamp = System.currentTimeMillis() + MILLIS_PER_MINUTE;
validityTimestamp = Long.toString(lValidityTimestamp);
}
mValidityTimestamp = lValidityTimestamp;
mPreferences.putString(PREF_VALIDITY_TIMESTAMP, validityTimestamp);
}
public long getValidityTimestamp() {
return mValidityTimestamp;
}
/**
* Set the retry until timestamp (GT) received from the server and add to
* preferences. You must manually call PreferenceObfuscator.commit() to
* commit these changes to disk.
*
* @param retryUntil the GT string received
*/
private void setRetryUntil(String retryUntil) {
Long lRetryUntil;
try {
lRetryUntil = Long.parseLong(retryUntil);
} catch (NumberFormatException e) {
// No response or not parsable, expire immediately
Log.w(TAG, "License retry timestamp (GT) missing, grace period disabled");
retryUntil = "0";
lRetryUntil = 0l;
}
mRetryUntil = lRetryUntil;
mPreferences.putString(PREF_RETRY_UNTIL, retryUntil);
}
public long getRetryUntil() {
return mRetryUntil;
}
/**
* Set the max retries value (GR) as received from the server and add to
* preferences. You must manually call PreferenceObfuscator.commit() to
* commit these changes to disk.
*
* @param maxRetries the GR string received
*/
private void setMaxRetries(String maxRetries) {
Long lMaxRetries;
try {
lMaxRetries = Long.parseLong(maxRetries);
} catch (NumberFormatException e) {
// No response or not parsable, expire immediately
Log.w(TAG, "Licence retry count (GR) missing, grace period disabled");
maxRetries = "0";
lMaxRetries = 0l;
}
mMaxRetries = lMaxRetries;
mPreferences.putString(PREF_MAX_RETRIES, maxRetries);
}
public long getMaxRetries() {
return mMaxRetries;
}
/**
* Set the license URL value (LU) as received from the server and add to preferences. You must
* manually call PreferenceObfuscator.commit() to commit these changes to disk.
*
* @param url the LU string received
*/
private void setLicensingUrl(String url) {
mLicensingUrl = url;
mPreferences.putString(PREF_LICENSING_URL, url);
}
public String getLicensingUrl() {
return mLicensingUrl;
}
/**
* {@inheritDoc}
*
* This implementation allows access if either:<br>
* <ol>
* <li>a LICENSED response was received within the validity period
* <li>a RETRY response was received in the last minute, and we are under
* the RETRY count or in the RETRY period.
* </ol>
*/
public boolean allowAccess() {
long ts = System.currentTimeMillis();
if (mLastResponse == Policy.LICENSED) {
// Check if the LICENSED response occurred within the validity timeout.
if (ts <= mValidityTimestamp) {
// Cached LICENSED response is still valid.
return true;
}
} else if (mLastResponse == Policy.RETRY &&
ts < mLastResponseTime + MILLIS_PER_MINUTE) {
// Only allow access if we are within the retry period or we haven't used up our
// max retries.
return (ts <= mRetryUntil || mRetryCount <= mMaxRetries);
}
return false;
}
private Map<String, String> decodeExtras(
com.google.android.vending.licensing.ResponseData rawData) {
Map<String, String> results = new HashMap<String, String>();
if (rawData == null) {
return results;
}
try {
URI rawExtras = new URI("?" + rawData.extra);
URIQueryDecoder.DecodeQuery(rawExtras, results);
} catch (URISyntaxException e) {
Log.w(TAG, "Invalid syntax error while decoding extras data from server.");
}
return results;
}
}
@@ -1,100 +0,0 @@
/*
* Copyright (C) 2010 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.google.android.vending.licensing;
import android.util.Log;
import com.google.android.vending.licensing.util.URIQueryDecoder;
import java.net.URI;
import java.net.URISyntaxException;
import java.util.HashMap;
import java.util.Map;
/**
* Non-caching policy. All requests will be sent to the licensing service,
* and no local caching is performed.
* <p>
* Using a non-caching policy ensures that there is no local preference data
* for malicious users to tamper with. As a side effect, applications
* will not be permitted to run while offline. Developers should carefully
* weigh the risks of using this Policy over one which implements caching,
* such as ServerManagedPolicy.
* <p>
* Access to the application is only allowed if a LICENSED response is.
* received. All other responses (including RETRY) will deny access.
*/
public class StrictPolicy implements Policy {
private static final String TAG = "StrictPolicy";
private int mLastResponse;
private String mLicensingUrl;
public StrictPolicy() {
// Set default policy. This will force the application to check the policy on launch.
mLastResponse = Policy.RETRY;
mLicensingUrl = null;
}
/**
* Process a new response from the license server. Since we aren't
* performing any caching, this equates to reading the LicenseResponse.
* Any cache-related ResponseData is ignored, but the licensing URL
* extra is still extracted in cases where the app is unlicensed.
*
* @param response the result from validating the server response
* @param rawData the raw server response data
*/
public void processServerResponse(int response, ResponseData rawData) {
mLastResponse = response;
if (response == Policy.NOT_LICENSED) {
Map<String, String> extras = decodeExtras(rawData);
mLicensingUrl = extras.get("LU");
}
}
/**
* {@inheritDoc}
*
* This implementation allows access if and only if a LICENSED response
* was received the last time the server was contacted.
*/
public boolean allowAccess() {
return (mLastResponse == Policy.LICENSED);
}
public String getLicensingUrl() {
return mLicensingUrl;
}
private Map<String, String> decodeExtras(
com.google.android.vending.licensing.ResponseData rawData) {
Map<String, String> results = new HashMap<String, String>();
if (rawData == null) {
return results;
}
try {
URI rawExtras = new URI("?" + rawData.extra);
URIQueryDecoder.DecodeQuery(rawExtras, results);
} catch (URISyntaxException e) {
Log.w(TAG, "Invalid syntax error while decoding extras data from server.");
}
return results;
}
}
@@ -1,33 +0,0 @@
/*
* Copyright (C) 2010 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.google.android.vending.licensing;
/**
* Indicates that an error occurred while validating the integrity of data managed by an
* {@link Obfuscator}.}
*/
public class ValidationException extends Exception {
public ValidationException() {
super();
}
public ValidationException(String s) {
super(s);
}
private static final long serialVersionUID = 1L;
}
@@ -1,578 +0,0 @@
// Portions copyright 2002, Google, Inc.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package com.google.android.vending.licensing.util;
// This code was converted from code at http://iharder.sourceforge.net/base64/
// Lots of extraneous features were removed.
/* The original code said:
* <p>
* I am placing this code in the Public Domain. Do with it as you will.
* This software comes with no guarantees or warranties but with
* plenty of well-wishing instead!
* Please visit
* <a href="http://iharder.net/xmlizable">http://iharder.net/xmlizable</a>
* periodically to check for updates or to contribute improvements.
* </p>
*
* @author Robert Harder
* @author rharder@usa.net
* @version 1.3
*/
// -- GODOT start --
import org.godotengine.godot.BuildConfig;
// -- GODOT end --
/**
* Base64 converter class. This code is not a full-blown MIME encoder;
* it simply converts binary data to base64 data and back.
*
* <p>Note {@link CharBase64} is a GWT-compatible implementation of this
* class.
*/
public class Base64 {
/** Specify encoding (value is {@code true}). */
public final static boolean ENCODE = true;
/** Specify decoding (value is {@code false}). */
public final static boolean DECODE = false;
/** The equals sign (=) as a byte. */
private final static byte EQUALS_SIGN = (byte) '=';
/** The new line character (\n) as a byte. */
private final static byte NEW_LINE = (byte) '\n';
/**
* The 64 valid Base64 values.
*/
private final static byte[] ALPHABET =
{(byte) 'A', (byte) 'B', (byte) 'C', (byte) 'D', (byte) 'E', (byte) 'F',
(byte) 'G', (byte) 'H', (byte) 'I', (byte) 'J', (byte) 'K',
(byte) 'L', (byte) 'M', (byte) 'N', (byte) 'O', (byte) 'P',
(byte) 'Q', (byte) 'R', (byte) 'S', (byte) 'T', (byte) 'U',
(byte) 'V', (byte) 'W', (byte) 'X', (byte) 'Y', (byte) 'Z',
(byte) 'a', (byte) 'b', (byte) 'c', (byte) 'd', (byte) 'e',
(byte) 'f', (byte) 'g', (byte) 'h', (byte) 'i', (byte) 'j',
(byte) 'k', (byte) 'l', (byte) 'm', (byte) 'n', (byte) 'o',
(byte) 'p', (byte) 'q', (byte) 'r', (byte) 's', (byte) 't',
(byte) 'u', (byte) 'v', (byte) 'w', (byte) 'x', (byte) 'y',
(byte) 'z', (byte) '0', (byte) '1', (byte) '2', (byte) '3',
(byte) '4', (byte) '5', (byte) '6', (byte) '7', (byte) '8',
(byte) '9', (byte) '+', (byte) '/'};
/**
* The 64 valid web safe Base64 values.
*/
private final static byte[] WEBSAFE_ALPHABET =
{(byte) 'A', (byte) 'B', (byte) 'C', (byte) 'D', (byte) 'E', (byte) 'F',
(byte) 'G', (byte) 'H', (byte) 'I', (byte) 'J', (byte) 'K',
(byte) 'L', (byte) 'M', (byte) 'N', (byte) 'O', (byte) 'P',
(byte) 'Q', (byte) 'R', (byte) 'S', (byte) 'T', (byte) 'U',
(byte) 'V', (byte) 'W', (byte) 'X', (byte) 'Y', (byte) 'Z',
(byte) 'a', (byte) 'b', (byte) 'c', (byte) 'd', (byte) 'e',
(byte) 'f', (byte) 'g', (byte) 'h', (byte) 'i', (byte) 'j',
(byte) 'k', (byte) 'l', (byte) 'm', (byte) 'n', (byte) 'o',
(byte) 'p', (byte) 'q', (byte) 'r', (byte) 's', (byte) 't',
(byte) 'u', (byte) 'v', (byte) 'w', (byte) 'x', (byte) 'y',
(byte) 'z', (byte) '0', (byte) '1', (byte) '2', (byte) '3',
(byte) '4', (byte) '5', (byte) '6', (byte) '7', (byte) '8',
(byte) '9', (byte) '-', (byte) '_'};
/**
* Translates a Base64 value to either its 6-bit reconstruction value
* or a negative number indicating some other meaning.
**/
private final static byte[] DECODABET = {-9, -9, -9, -9, -9, -9, -9, -9, -9, // Decimal 0 - 8
-5, -5, // Whitespace: Tab and Linefeed
-9, -9, // Decimal 11 - 12
-5, // Whitespace: Carriage Return
-9, -9, -9, -9, -9, -9, -9, -9, -9, -9, -9, -9, -9, // Decimal 14 - 26
-9, -9, -9, -9, -9, // Decimal 27 - 31
-5, // Whitespace: Space
-9, -9, -9, -9, -9, -9, -9, -9, -9, -9, // Decimal 33 - 42
62, // Plus sign at decimal 43
-9, -9, -9, // Decimal 44 - 46
63, // Slash at decimal 47
52, 53, 54, 55, 56, 57, 58, 59, 60, 61, // Numbers zero through nine
-9, -9, -9, // Decimal 58 - 60
-1, // Equals sign at decimal 61
-9, -9, -9, // Decimal 62 - 64
0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, // Letters 'A' through 'N'
14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, // Letters 'O' through 'Z'
-9, -9, -9, -9, -9, -9, // Decimal 91 - 96
26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, // Letters 'a' through 'm'
39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51, // Letters 'n' through 'z'
-9, -9, -9, -9, -9 // Decimal 123 - 127
/* ,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9, // Decimal 128 - 139
-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9, // Decimal 140 - 152
-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9, // Decimal 153 - 165
-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9, // Decimal 166 - 178
-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9, // Decimal 179 - 191
-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9, // Decimal 192 - 204
-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9, // Decimal 205 - 217
-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9, // Decimal 218 - 230
-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9, // Decimal 231 - 243
-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9 // Decimal 244 - 255 */
};
/** The web safe decodabet */
private final static byte[] WEBSAFE_DECODABET =
{-9, -9, -9, -9, -9, -9, -9, -9, -9, // Decimal 0 - 8
-5, -5, // Whitespace: Tab and Linefeed
-9, -9, // Decimal 11 - 12
-5, // Whitespace: Carriage Return
-9, -9, -9, -9, -9, -9, -9, -9, -9, -9, -9, -9, -9, // Decimal 14 - 26
-9, -9, -9, -9, -9, // Decimal 27 - 31
-5, // Whitespace: Space
-9, -9, -9, -9, -9, -9, -9, -9, -9, -9, -9, -9, // Decimal 33 - 44
62, // Dash '-' sign at decimal 45
-9, -9, // Decimal 46-47
52, 53, 54, 55, 56, 57, 58, 59, 60, 61, // Numbers zero through nine
-9, -9, -9, // Decimal 58 - 60
-1, // Equals sign at decimal 61
-9, -9, -9, // Decimal 62 - 64
0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, // Letters 'A' through 'N'
14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, // Letters 'O' through 'Z'
-9, -9, -9, -9, // Decimal 91-94
63, // Underscore '_' at decimal 95
-9, // Decimal 96
26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, // Letters 'a' through 'm'
39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51, // Letters 'n' through 'z'
-9, -9, -9, -9, -9 // Decimal 123 - 127
/* ,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9, // Decimal 128 - 139
-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9, // Decimal 140 - 152
-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9, // Decimal 153 - 165
-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9, // Decimal 166 - 178
-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9, // Decimal 179 - 191
-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9, // Decimal 192 - 204
-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9, // Decimal 205 - 217
-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9, // Decimal 218 - 230
-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9, // Decimal 231 - 243
-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9 // Decimal 244 - 255 */
};
// Indicates white space in encoding
private final static byte WHITE_SPACE_ENC = -5;
// Indicates equals sign in encoding
private final static byte EQUALS_SIGN_ENC = -1;
/** Defeats instantiation. */
private Base64() {
}
/* ******** E N C O D I N G M E T H O D S ******** */
/**
* Encodes up to three bytes of the array <var>source</var>
* and writes the resulting four Base64 bytes to <var>destination</var>.
* The source and destination arrays can be manipulated
* anywhere along their length by specifying
* <var>srcOffset</var> and <var>destOffset</var>.
* This method does not check to make sure your arrays
* are large enough to accommodate <var>srcOffset</var> + 3 for
* the <var>source</var> array or <var>destOffset</var> + 4 for
* the <var>destination</var> array.
* The actual number of significant bytes in your array is
* given by <var>numSigBytes</var>.
*
* @param source the array to convert
* @param srcOffset the index where conversion begins
* @param numSigBytes the number of significant bytes in your array
* @param destination the array to hold the conversion
* @param destOffset the index where output will be put
* @param alphabet is the encoding alphabet
* @return the <var>destination</var> array
* @since 1.3
*/
private static byte[] encode3to4(byte[] source, int srcOffset,
int numSigBytes, byte[] destination, int destOffset, byte[] alphabet) {
// 1 2 3
// 01234567890123456789012345678901 Bit position
// --------000000001111111122222222 Array position from threeBytes
// --------| || || || | Six bit groups to index alphabet
// >>18 >>12 >> 6 >> 0 Right shift necessary
// 0x3f 0x3f 0x3f Additional AND
// Create buffer with zero-padding if there are only one or two
// significant bytes passed in the array.
// We have to shift left 24 in order to flush out the 1's that appear
// when Java treats a value as negative that is cast from a byte to an int.
int inBuff =
(numSigBytes > 0 ? ((source[srcOffset] << 24) >>> 8) : 0)
| (numSigBytes > 1 ? ((source[srcOffset + 1] << 24) >>> 16) : 0)
| (numSigBytes > 2 ? ((source[srcOffset + 2] << 24) >>> 24) : 0);
switch (numSigBytes) {
case 3:
destination[destOffset] = alphabet[(inBuff >>> 18)];
destination[destOffset + 1] = alphabet[(inBuff >>> 12) & 0x3f];
destination[destOffset + 2] = alphabet[(inBuff >>> 6) & 0x3f];
destination[destOffset + 3] = alphabet[(inBuff) & 0x3f];
return destination;
case 2:
destination[destOffset] = alphabet[(inBuff >>> 18)];
destination[destOffset + 1] = alphabet[(inBuff >>> 12) & 0x3f];
destination[destOffset + 2] = alphabet[(inBuff >>> 6) & 0x3f];
destination[destOffset + 3] = EQUALS_SIGN;
return destination;
case 1:
destination[destOffset] = alphabet[(inBuff >>> 18)];
destination[destOffset + 1] = alphabet[(inBuff >>> 12) & 0x3f];
destination[destOffset + 2] = EQUALS_SIGN;
destination[destOffset + 3] = EQUALS_SIGN;
return destination;
default:
return destination;
} // end switch
} // end encode3to4
/**
* Encodes a byte array into Base64 notation.
* Equivalent to calling
* {@code encodeBytes(source, 0, source.length)}
*
* @param source The data to convert
* @since 1.4
*/
public static String encode(byte[] source) {
return encode(source, 0, source.length, ALPHABET, true);
}
/**
* Encodes a byte array into web safe Base64 notation.
*
* @param source The data to convert
* @param doPadding is {@code true} to pad result with '=' chars
* if it does not fall on 3 byte boundaries
*/
public static String encodeWebSafe(byte[] source, boolean doPadding) {
return encode(source, 0, source.length, WEBSAFE_ALPHABET, doPadding);
}
/**
* Encodes a byte array into Base64 notation.
*
* @param source The data to convert
* @param off Offset in array where conversion should begin
* @param len Length of data to convert
* @param alphabet is the encoding alphabet
* @param doPadding is {@code true} to pad result with '=' chars
* if it does not fall on 3 byte boundaries
* @since 1.4
*/
public static String encode(byte[] source, int off, int len, byte[] alphabet,
boolean doPadding) {
byte[] outBuff = encode(source, off, len, alphabet, Integer.MAX_VALUE);
int outLen = outBuff.length;
// If doPadding is false, set length to truncate '='
// padding characters
while (doPadding == false && outLen > 0) {
if (outBuff[outLen - 1] != '=') {
break;
}
outLen -= 1;
}
return new String(outBuff, 0, outLen);
}
/**
* Encodes a byte array into Base64 notation.
*
* @param source The data to convert
* @param off Offset in array where conversion should begin
* @param len Length of data to convert
* @param alphabet is the encoding alphabet
* @param maxLineLength maximum length of one line.
* @return the BASE64-encoded byte array
*/
public static byte[] encode(byte[] source, int off, int len, byte[] alphabet,
int maxLineLength) {
int lenDiv3 = (len + 2) / 3; // ceil(len / 3)
int len43 = lenDiv3 * 4;
byte[] outBuff = new byte[len43 // Main 4:3
+ (len43 / maxLineLength)]; // New lines
int d = 0;
int e = 0;
int len2 = len - 2;
int lineLength = 0;
for (; d < len2; d += 3, e += 4) {
// The following block of code is the same as
// encode3to4( source, d + off, 3, outBuff, e, alphabet );
// but inlined for faster encoding (~20% improvement)
int inBuff =
((source[d + off] << 24) >>> 8)
| ((source[d + 1 + off] << 24) >>> 16)
| ((source[d + 2 + off] << 24) >>> 24);
outBuff[e] = alphabet[(inBuff >>> 18)];
outBuff[e + 1] = alphabet[(inBuff >>> 12) & 0x3f];
outBuff[e + 2] = alphabet[(inBuff >>> 6) & 0x3f];
outBuff[e + 3] = alphabet[(inBuff) & 0x3f];
lineLength += 4;
if (lineLength == maxLineLength) {
outBuff[e + 4] = NEW_LINE;
e++;
lineLength = 0;
} // end if: end of line
} // end for: each piece of array
if (d < len) {
encode3to4(source, d + off, len - d, outBuff, e, alphabet);
lineLength += 4;
if (lineLength == maxLineLength) {
// Add a last newline
outBuff[e + 4] = NEW_LINE;
e++;
}
e += 4;
}
// -- GODOT start --
//assert (e == outBuff.length);
if (BuildConfig.DEBUG && e != outBuff.length)
throw new RuntimeException();
// -- GODOT end --
return outBuff;
}
/* ******** D E C O D I N G M E T H O D S ******** */
/**
* Decodes four bytes from array <var>source</var>
* and writes the resulting bytes (up to three of them)
* to <var>destination</var>.
* The source and destination arrays can be manipulated
* anywhere along their length by specifying
* <var>srcOffset</var> and <var>destOffset</var>.
* This method does not check to make sure your arrays
* are large enough to accommodate <var>srcOffset</var> + 4 for
* the <var>source</var> array or <var>destOffset</var> + 3 for
* the <var>destination</var> array.
* This method returns the actual number of bytes that
* were converted from the Base64 encoding.
*
*
* @param source the array to convert
* @param srcOffset the index where conversion begins
* @param destination the array to hold the conversion
* @param destOffset the index where output will be put
* @param decodabet the decodabet for decoding Base64 content
* @return the number of decoded bytes converted
* @since 1.3
*/
private static int decode4to3(byte[] source, int srcOffset,
byte[] destination, int destOffset, byte[] decodabet) {
// Example: Dk==
if (source[srcOffset + 2] == EQUALS_SIGN) {
int outBuff =
((decodabet[source[srcOffset]] << 24) >>> 6)
| ((decodabet[source[srcOffset + 1]] << 24) >>> 12);
destination[destOffset] = (byte) (outBuff >>> 16);
return 1;
} else if (source[srcOffset + 3] == EQUALS_SIGN) {
// Example: DkL=
int outBuff =
((decodabet[source[srcOffset]] << 24) >>> 6)
| ((decodabet[source[srcOffset + 1]] << 24) >>> 12)
| ((decodabet[source[srcOffset + 2]] << 24) >>> 18);
destination[destOffset] = (byte) (outBuff >>> 16);
destination[destOffset + 1] = (byte) (outBuff >>> 8);
return 2;
} else {
// Example: DkLE
int outBuff =
((decodabet[source[srcOffset]] << 24) >>> 6)
| ((decodabet[source[srcOffset + 1]] << 24) >>> 12)
| ((decodabet[source[srcOffset + 2]] << 24) >>> 18)
| ((decodabet[source[srcOffset + 3]] << 24) >>> 24);
destination[destOffset] = (byte) (outBuff >> 16);
destination[destOffset + 1] = (byte) (outBuff >> 8);
destination[destOffset + 2] = (byte) (outBuff);
return 3;
}
} // end decodeToBytes
/**
* Decodes data from Base64 notation.
*
* @param s the string to decode (decoded in default encoding)
* @return the decoded data
* @since 1.4
*/
public static byte[] decode(String s) throws Base64DecoderException {
byte[] bytes = s.getBytes();
return decode(bytes, 0, bytes.length);
}
/**
* Decodes data from web safe Base64 notation.
* Web safe encoding uses '-' instead of '+', '_' instead of '/'
*
* @param s the string to decode (decoded in default encoding)
* @return the decoded data
*/
public static byte[] decodeWebSafe(String s) throws Base64DecoderException {
byte[] bytes = s.getBytes();
return decodeWebSafe(bytes, 0, bytes.length);
}
/**
* Decodes Base64 content in byte array format and returns
* the decoded byte array.
*
* @param source The Base64 encoded data
* @return decoded data
* @since 1.3
* @throws Base64DecoderException
*/
public static byte[] decode(byte[] source) throws Base64DecoderException {
return decode(source, 0, source.length);
}
/**
* Decodes web safe Base64 content in byte array format and returns
* the decoded data.
* Web safe encoding uses '-' instead of '+', '_' instead of '/'
*
* @param source the string to decode (decoded in default encoding)
* @return the decoded data
*/
public static byte[] decodeWebSafe(byte[] source)
throws Base64DecoderException {
return decodeWebSafe(source, 0, source.length);
}
/**
* Decodes Base64 content in byte array format and returns
* the decoded byte array.
*
* @param source The Base64 encoded data
* @param off The offset of where to begin decoding
* @param len The length of characters to decode
* @return decoded data
* @since 1.3
* @throws Base64DecoderException
*/
public static byte[] decode(byte[] source, int off, int len)
throws Base64DecoderException {
return decode(source, off, len, DECODABET);
}
/**
* Decodes web safe Base64 content in byte array format and returns
* the decoded byte array.
* Web safe encoding uses '-' instead of '+', '_' instead of '/'
*
* @param source The Base64 encoded data
* @param off The offset of where to begin decoding
* @param len The length of characters to decode
* @return decoded data
*/
public static byte[] decodeWebSafe(byte[] source, int off, int len)
throws Base64DecoderException {
return decode(source, off, len, WEBSAFE_DECODABET);
}
/**
* Decodes Base64 content using the supplied decodabet and returns
* the decoded byte array.
*
* @param source The Base64 encoded data
* @param off The offset of where to begin decoding
* @param len The length of characters to decode
* @param decodabet the decodabet for decoding Base64 content
* @return decoded data
*/
public static byte[] decode(byte[] source, int off, int len, byte[] decodabet)
throws Base64DecoderException {
int len34 = len * 3 / 4;
byte[] outBuff = new byte[2 + len34]; // Upper limit on size of output
int outBuffPosn = 0;
byte[] b4 = new byte[4];
int b4Posn = 0;
int i = 0;
byte sbiCrop = 0;
byte sbiDecode = 0;
for (i = 0; i < len; i++) {
sbiCrop = (byte) (source[i + off] & 0x7f); // Only the low seven bits
sbiDecode = decodabet[sbiCrop];
if (sbiDecode >= WHITE_SPACE_ENC) { // White space Equals sign or better
if (sbiDecode >= EQUALS_SIGN_ENC) {
// An equals sign (for padding) must not occur at position 0 or 1
// and must be the last byte[s] in the encoded value
if (sbiCrop == EQUALS_SIGN) {
int bytesLeft = len - i;
byte lastByte = (byte) (source[len - 1 + off] & 0x7f);
if (b4Posn == 0 || b4Posn == 1) {
throw new Base64DecoderException(
"invalid padding byte '=' at byte offset " + i);
} else if ((b4Posn == 3 && bytesLeft > 2)
|| (b4Posn == 4 && bytesLeft > 1)) {
throw new Base64DecoderException(
"padding byte '=' falsely signals end of encoded value "
+ "at offset " + i);
} else if (lastByte != EQUALS_SIGN && lastByte != NEW_LINE) {
throw new Base64DecoderException(
"encoded value has invalid trailing byte");
}
break;
}
b4[b4Posn++] = sbiCrop;
if (b4Posn == 4) {
outBuffPosn += decode4to3(b4, 0, outBuff, outBuffPosn, decodabet);
b4Posn = 0;
}
}
} else {
throw new Base64DecoderException("Bad Base64 input character at " + i
+ ": " + source[i + off] + "(decimal)");
}
}
// Because web safe encoding allows non padding base64 encodes, we
// need to pad the rest of the b4 buffer with equal signs when
// b4Posn != 0. There can be at most 2 equal signs at the end of
// four characters, so the b4 buffer must have two or three
// characters. This also catches the case where the input is
// padded with EQUALS_SIGN
if (b4Posn != 0) {
if (b4Posn == 1) {
throw new Base64DecoderException("single trailing character at offset "
+ (len - 1));
}
b4[b4Posn++] = EQUALS_SIGN;
outBuffPosn += decode4to3(b4, 0, outBuff, outBuffPosn, decodabet);
}
byte[] out = new byte[outBuffPosn];
System.arraycopy(outBuff, 0, out, 0, outBuffPosn);
return out;
}
}
@@ -1,32 +0,0 @@
// Copyright 2002, Google, Inc.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package com.google.android.vending.licensing.util;
/**
* Exception thrown when encountering an invalid Base64 input character.
*
* @author nelson
*/
public class Base64DecoderException extends Exception {
public Base64DecoderException() {
super();
}
public Base64DecoderException(String s) {
super(s);
}
private static final long serialVersionUID = 1L;
}
@@ -1,60 +0,0 @@
/*
* Copyright (C) 2016 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.google.android.vending.licensing.util;
import android.util.Log;
import java.io.UnsupportedEncodingException;
import java.net.URI;
import java.net.URLDecoder;
import java.util.Map;
import java.util.Scanner;
public class URIQueryDecoder {
private static final String TAG = "URIQueryDecoder";
/**
* Decodes the query portion of the passed-in URI.
*
* @param encodedURI the URI containing the query to decode
* @param results a map containing all query parameters. Query parameters that do not have a
* value will map to a null string
*/
static public void DecodeQuery(URI encodedURI, Map<String, String> results) {
Scanner scanner = new Scanner(encodedURI.getRawQuery());
scanner.useDelimiter("&");
try {
while (scanner.hasNext()) {
String param = scanner.next();
String[] valuePair = param.split("=");
String name, value;
if (valuePair.length == 1) {
value = null;
} else if (valuePair.length == 2) {
value = URLDecoder.decode(valuePair[1], "UTF-8");
} else {
throw new IllegalArgumentException("query parameter invalid");
}
name = URLDecoder.decode(valuePair[0], "UTF-8");
results.put(name, value);
}
} catch (UnsupportedEncodingException e) {
// This should never happen.
Log.e(TAG, "UTF-8 Not Recognized as a charset. Device configuration Error.");
}
}
}
@@ -31,11 +31,16 @@
package org.godotengine.godot;
import java.util.HashMap;
import java.util.Map;
import java.util.Set;
public class Dictionary extends HashMap<String, Object> {
protected String[] keys_cache;
public final class Dictionary extends HashMap<String, Object> {
private String[] keys_cache;
/**
* @deprecated Use {@link java.util.HashMap#keySet()} instead.
*/
@Deprecated
public String[] get_keys() {
String[] ret = new String[size()];
int i = 0;
@@ -48,6 +53,10 @@ public class Dictionary extends HashMap<String, Object> {
return ret;
}
/**
* @deprecated Use {@link java.util.HashMap#values()} instead.
*/
@Deprecated
public Object[] get_values() {
Object[] ret = new Object[size()];
int i = 0;
@@ -60,10 +69,18 @@ public class Dictionary extends HashMap<String, Object> {
return ret;
}
/**
* @deprecated Use {@link java.util.HashMap#putAll(Map)} instead.
*/
@Deprecated
public void set_keys(String[] keys) {
keys_cache = keys;
}
/**
* @deprecated Use {@link java.util.HashMap#putAll(Map)} instead.
*/
@Deprecated
public void set_values(Object[] vals) {
int i = 0;
for (String key : keys_cache) {
@@ -30,7 +30,6 @@
package org.godotengine.godot
import android.annotation.SuppressLint
import android.app.Activity
import android.app.AlertDialog
import android.content.*
@@ -55,44 +54,36 @@ import androidx.core.view.WindowCompat
import androidx.core.view.WindowInsetsAnimationCompat
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.WindowInsetsControllerCompat
import com.google.android.vending.expansion.downloader.*
import org.godotengine.godot.error.Error
import org.godotengine.godot.input.GodotEditText
import org.godotengine.godot.input.GodotInputHandler
import org.godotengine.godot.io.FilePicker
import org.godotengine.godot.io.StorageScope
import org.godotengine.godot.io.directory.DirectoryAccessHandler
import org.godotengine.godot.io.file.FileAccessHandler
import org.godotengine.godot.nativeapi.GodotNativeBridge
import org.godotengine.godot.plugin.AndroidRuntimePlugin
import org.godotengine.godot.plugin.GodotPlugin
import org.godotengine.godot.plugin.GodotPluginRegistry
import org.godotengine.godot.tts.GodotTTS
import org.godotengine.godot.utils.DialogUtils
import org.godotengine.godot.utils.GodotNetUtils
import org.godotengine.godot.utils.PermissionsUtil
import org.godotengine.godot.utils.PermissionsUtil.requestPermission
import org.godotengine.godot.utils.beginBenchmarkMeasure
import org.godotengine.godot.utils.benchmarkFile
import org.godotengine.godot.utils.dumpBenchmark
import org.godotengine.godot.utils.endBenchmarkMeasure
import org.godotengine.godot.utils.useBenchmark
import org.godotengine.godot.variant.Callable as GodotCallable
import org.godotengine.godot.xr.XRMode
import java.io.File
import java.io.FileInputStream
import java.io.InputStream
import java.security.MessageDigest
import java.util.*
import java.util.concurrent.Callable
import java.util.concurrent.FutureTask
import java.util.concurrent.atomic.AtomicBoolean
import java.util.concurrent.atomic.AtomicReference
/**
* Core component used to interface with the native layer of the engine.
*
* Can be hosted by [Activity], [Fragment] or [Service] android components, so long as its
* lifecycle methods are properly invoked.
* Can be hosted by [Activity], [androidx.fragment.app.Fragment] or [android.app.Service] android components, so long
* as its lifecycle methods are properly invoked.
*/
class Godot private constructor(val context: Context) {
@@ -115,15 +106,30 @@ class Godot private constructor(val context: Context) {
private const val TEMPLATE_FLAVOR = "template"
/**
* @return true if this is an editor build, false if this is a template build
* @return true if this is an editor build, false if this is a template build.
*/
internal fun isEditorBuild() = BuildConfig.FLAVOR == EDITOR_FLAVOR
/**
* @return true if this is a template build, false if this is an editor build.
*/
internal fun isTemplateBuild() = BuildConfig.FLAVOR == TEMPLATE_FLAVOR
}
/**
* Describes the engine current run status.
*/
enum class RunStatus {
INITIALIZING,
STARTED,
TERMINATING
}
private val godotNativeBridge = GodotNativeBridge(this)
private val mSensorManager: SensorManager? by lazy { context.getSystemService(Context.SENSOR_SERVICE) as? SensorManager }
private val mClipboard: ClipboardManager? by lazy { context.getSystemService(Context.CLIPBOARD_SERVICE) as? ClipboardManager }
private val vibratorService: Vibrator? by lazy { context.getSystemService(Context.VIBRATOR_SERVICE) as? Vibrator }
private val pluginRegistry: GodotPluginRegistry by lazy { GodotPluginRegistry.getPluginRegistry() }
internal val pluginRegistry: GodotPluginRegistry by lazy { GodotPluginRegistry.getPluginRegistry() }
private val accelerometerEnabled = AtomicBoolean(false)
private val mAccelerometer: Sensor? by lazy { mSensorManager?.getDefaultSensor(Sensor.TYPE_ACCELEROMETER) }
@@ -143,7 +149,7 @@ class Godot private constructor(val context: Context) {
val directoryAccessHandler = DirectoryAccessHandler(context)
val fileAccessHandler = FileAccessHandler(context)
val netUtils = GodotNetUtils(context)
private val godotInputHandler = GodotInputHandler(context, this)
val godotInputHandler = GodotInputHandler(context, this)
private val hasClipboardCallable = Callable {
mClipboard?.hasPrimaryClip() == true
@@ -174,8 +180,7 @@ class Godot private constructor(val context: Context) {
* Tracks whether [onInitRenderView] was completed successfully.
*/
private var renderViewInitialized = false
private var primaryHost: GodotHost? = null
private var currentConfig = context.resources.configuration
internal var primaryHost: GodotHost? = null
/**
* Tracks whether we're in the RESUMED lifecycle state.
@@ -184,25 +189,30 @@ class Godot private constructor(val context: Context) {
private var resumed = false
/**
* Tracks whether [onGodotSetupCompleted] fired.
* Tracks the engine's run status.
*/
private val godotMainLoopStarted = AtomicBoolean(false)
private val _runStatus = AtomicReference<RunStatus>(RunStatus.INITIALIZING)
val runStatus: RunStatus
get() = _runStatus.get()
val io = GodotIO(this)
private var commandLine : MutableList<String> = ArrayList<String>()
private var xrMode = XRMode.REGULAR
internal var xrMode = XRMode.REGULAR
private val useImmersive = AtomicBoolean(false)
private val isEdgeToEdge = AtomicBoolean(false)
private var useDebugOpengl = false
private var darkMode = false
internal var darkMode = false
private var backgroundColor: Int = Color.BLACK
private var orientation = Configuration.ORIENTATION_UNDEFINED
var disableGodotSplash = false
private set
internal var containerLayout: FrameLayout? = null
var renderView: GodotRenderView? = null
/**
* Returns true if the native engine has been initialized through [onInitNativeLayer], false otherwise.
* Returns true if the native engine has been initialized through [initEngine], false otherwise.
*/
private fun isNativeInitialized() = nativeLayerInitializeCompleted && nativeLayerSetupCompleted
@@ -212,7 +222,7 @@ class Godot private constructor(val context: Context) {
fun isInitialized() = primaryHost != null && isNativeInitialized() && renderViewInitialized
/**
* Provides access to the primary host [Activity]
* Provides access to the primary host [Activity].
*/
fun getActivity() = primaryHost?.activity
@@ -222,9 +232,6 @@ class Godot private constructor(val context: Context) {
* This must be followed by [onInitRenderView] to complete initialization of the engine.
*
* @return false if initialization of the native layer fails, true otherwise.
*
* @throws IllegalArgumentException exception if the specified expansion pack (if any)
* is invalid.
*/
fun initEngine(host: GodotHost?, commandLineParams: List<String>, hostPlugins: Set<GodotPlugin> = Collections.emptySet()): Boolean {
if (isNativeInitialized()) {
@@ -232,25 +239,37 @@ class Godot private constructor(val context: Context) {
return true
}
Log.v(TAG, "InitEngine with params: $commandLineParams")
darkMode = context.resources?.configuration?.uiMode?.and(Configuration.UI_MODE_NIGHT_MASK) == Configuration.UI_MODE_NIGHT_YES
val config = context.resources.configuration
darkMode = (config.uiMode and Configuration.UI_MODE_NIGHT_MASK) == Configuration.UI_MODE_NIGHT_YES
orientation = config.orientation
beginBenchmarkMeasure("Startup", "Godot::initEngine")
try {
this.primaryHost = host
commandLine.addAll(commandLineParams)
Log.v(TAG, "Initializing Godot plugin registry")
val runtimePlugins = mutableSetOf<GodotPlugin>(AndroidRuntimePlugin(this))
runtimePlugins.addAll(hostPlugins)
GodotPluginRegistry.initializePluginRegistry(this, runtimePlugins)
// check for apk expansion API
commandLine.addAll(commandLineParams)
var mainPackMd5: String? = null
var mainPackKey: String? = null
var useApkExpansion = false
// Let the plugins take a peek at the command line params and provide their own params if desired.
val originalCommandLineParams = Collections.unmodifiableList(commandLineParams)
for (plugin in pluginRegistry.allPlugins) {
try {
val pluginCommandLineParams = plugin.getCommandLineParams(originalCommandLineParams)
if (pluginCommandLineParams != originalCommandLineParams && pluginCommandLineParams.isNotEmpty()) {
Log.d(TAG, "Received command line params from plugin $plugin: $pluginCommandLineParams")
commandLine.addAll(pluginCommandLineParams)
}
} catch (e: Exception) {
Log.e(TAG, "Unable to get command line params from plugin $plugin", e)
}
}
Log.v(TAG, "InitEngine with params: $commandLine")
val newArgs: MutableList<String> = ArrayList()
var useApkExpansion = false
var i = 0
while (i < commandLine.size) {
val hasExtra: Boolean = i < commandLine.size - 1
@@ -265,23 +284,11 @@ class Godot private constructor(val context: Context) {
} else if (commandLine[i] == "--fullscreen") {
useImmersive.set(true)
newArgs.add(commandLine[i])
} else if (commandLine[i] == "--background_color") {
} else if (hasExtra && commandLine[i] == "--background_color") {
setWindowColor(commandLine[i + 1])
} else if (commandLine[i] == "--use_apk_expansion") {
useApkExpansion = true
} else if (hasExtra && commandLine[i] == "--apk_expansion_md5") {
mainPackMd5 = commandLine[i + 1]
i++
} else if (hasExtra && commandLine[i] == "--apk_expansion_key") {
mainPackKey = commandLine[i + 1]
val prefs = context.getSharedPreferences(
"app_data_keys",
Context.MODE_PRIVATE
)
val editor = prefs.edit()
editor.putString("store_public_key", mainPackKey)
editor.apply()
i++
} else if (commandLine[i] == "--disable_godot_splash") {
disableGodotSplash = true
} else if (commandLine[i] == "--benchmark") {
useBenchmark = true
newArgs.add(commandLine[i])
@@ -294,49 +301,28 @@ class Godot private constructor(val context: Context) {
newArgs.add(commandLine[i + 1])
i++
} else if (commandLine[i].trim().isNotEmpty()) {
} else if (hasExtra && commandLine[i] == "--main-pack") {
newArgs.add(commandLine[i])
val mainPackPath = commandLine[i + 1]
newArgs.add(commandLine[i + 1])
// Check the storage scope of the main pack path. For template builds, `useApkExpansion` is enabled
// if the storage scope is APP.
val storageScope = fileAccessHandler.storageScopeIdentifier.identifyStorageScope(mainPackPath)
if (isTemplateBuild()) {
useApkExpansion = storageScope == StorageScope.APP
}
i++
} else if (commandLine[i].trim().isNotEmpty()) { // This block should always be last!
newArgs.add(commandLine[i])
}
i++
}
var expansionPackPath = ""
commandLine = if (newArgs.isEmpty()) { mutableListOf() } else { newArgs }
if (useApkExpansion && mainPackMd5 != null && mainPackKey != null) {
// Build the full path to the app's expansion files
try {
expansionPackPath = Helpers.getSaveFilePath(context)
expansionPackPath += "/main." + context.packageManager.getPackageInfo(
context.packageName,
0
).versionCode + "." + context.packageName + ".obb"
} catch (e: java.lang.Exception) {
Log.e(TAG, "Unable to build full path to the app's expansion files", e)
}
val f = File(expansionPackPath)
var packValid = true
if (!f.exists()) {
packValid = false
} else if (obbIsCorrupted(expansionPackPath, mainPackMd5)) {
packValid = false
try {
f.delete()
} catch (_: java.lang.Exception) {
}
}
if (!packValid) {
// Aborting engine initialization
throw IllegalArgumentException("Invalid expansion pack")
}
}
if (expansionPackPath.isNotEmpty()) {
commandLine.add("--main-pack")
commandLine.add(expansionPackPath)
}
if (!nativeLayerInitializeCompleted) {
nativeLayerInitializeCompleted = GodotLib.initialize(
this,
godotNativeBridge,
context.assets,
io,
netUtils,
@@ -348,6 +334,7 @@ class Godot private constructor(val context: Context) {
}
if (nativeLayerInitializeCompleted && !nativeLayerSetupCompleted) {
Log.v(TAG, "Setting up native layer with params: $commandLine")
nativeLayerSetupCompleted = GodotLib.setup(commandLine.toTypedArray(), tts)
if (!nativeLayerSetupCompleted) {
throw IllegalStateException("Unable to setup the Godot engine! Aborting...")
@@ -459,20 +446,8 @@ class Godot private constructor(val context: Context) {
}
}
/**
* Invoked from the render thread to toggle the immersive mode.
*/
@Keep
private fun nativeEnableImmersiveMode(enabled: Boolean) {
runOnHostThread {
enableImmersiveMode(enabled)
}
}
@Keep
fun isInImmersiveMode() = useImmersive.get()
@Keep
fun isInEdgeToEdgeMode() = isEdgeToEdge.get()
fun setSystemBarsAppearance() {
@@ -565,19 +540,14 @@ class Godot private constructor(val context: Context) {
!isEditorHint() &&
java.lang.Boolean.parseBoolean(GodotLib.getGlobal("display/window/per_pixel_transparency/allowed"))
Log.d(TAG, "Render view should be transparent: $shouldBeTransparent")
renderView = if (usesVulkan()) {
if (meetsVulkanRequirements(context.packageManager)) {
GodotVulkanRenderView(this, godotInputHandler, shouldBeTransparent)
} else if (canFallbackToOpenGL()) {
// Fallback to OpenGl.
GodotGLRenderView(this, godotInputHandler, xrMode, useDebugOpengl, shouldBeTransparent)
} else {
throw IllegalStateException(context.getString(R.string.error_missing_vulkan_requirements_message))
}
val nativeRenderer = getNativeRenderer()
if (nativeRenderer == "vulkan") {
renderView = GodotVulkanRenderView(this, godotInputHandler, shouldBeTransparent)
} else if (nativeRenderer == "opengl3") {
renderView = GodotGLRenderView(this, godotInputHandler, xrMode, useDebugOpengl, shouldBeTransparent)
} else {
// Fallback to OpenGl.
GodotGLRenderView(this, godotInputHandler, xrMode, useDebugOpengl, shouldBeTransparent)
throw IllegalStateException("No native renderer is available.")
}
renderView?.let {
@@ -641,7 +611,7 @@ class Godot private constructor(val context: Context) {
// Fixes an issue on Android 10 and older where immersive mode gets auto disabled after the keyboard is hidden on some devices.
if (useImmersive.get() && Build.VERSION.SDK_INT < Build.VERSION_CODES.R) {
runOnHostThread {
enableImmersiveMode(true, true)
enableImmersiveMode(enabled = true, override = true)
}
}
}
@@ -649,6 +619,8 @@ class Godot private constructor(val context: Context) {
renderView?.queueOnRenderThread {
for (plugin in pluginRegistry.allPlugins) {
// Plugins should be registered early so they are available as soon as the app starts.
// Otherwise, a delay in registration may make them unavailable during _init() of the main script or an autoload.
plugin.onRegisterPluginWithGodotNative()
}
setKeepScreenOn(java.lang.Boolean.parseBoolean(GodotLib.getGlobal("display/window/energy_saving/keep_screen_on")))
@@ -684,6 +656,9 @@ class Godot private constructor(val context: Context) {
}
renderView?.onActivityStarted()
for (plugin in pluginRegistry.allPlugins) {
plugin.onMainStart()
}
}
fun onResume(host: GodotHost) {
@@ -701,7 +676,7 @@ class Godot private constructor(val context: Context) {
}
private fun registerSensorsIfNeeded() {
if (!resumed || !godotMainLoopStarted.get()) {
if (!resumed || runStatus != RunStatus.STARTED) {
return
}
@@ -719,6 +694,13 @@ class Godot private constructor(val context: Context) {
}
}
internal fun onPictureInPictureModeChanged(isInPictureInPictureMode: Boolean) {
Log.v(TAG, "onPictureInPictureModeChanged: $isInPictureInPictureMode")
runOnRenderThread {
GodotLib.onPictureInPictureModeChanged(isInPictureInPictureMode)
}
}
fun onPause(host: GodotHost) {
Log.v(TAG, "OnPause: $host")
resumed = false
@@ -726,11 +708,11 @@ class Godot private constructor(val context: Context) {
return
}
renderView?.onActivityPaused()
mSensorManager?.unregisterListener(godotInputHandler)
for (plugin in pluginRegistry.allPlugins) {
plugin.onMainPause()
}
renderView?.onActivityPaused()
mSensorManager?.unregisterListener(godotInputHandler)
}
fun onStop(host: GodotHost) {
@@ -739,6 +721,9 @@ class Godot private constructor(val context: Context) {
return
}
for (plugin in pluginRegistry.allPlugins) {
plugin.onMainStop()
}
renderView?.onActivityStopped()
}
@@ -765,7 +750,7 @@ class Godot private constructor(val context: Context) {
* Configuration change callback
*/
fun onConfigurationChanged(newConfig: Configuration) {
renderView?.inputHandler?.onConfigurationChanged(newConfig)
godotInputHandler.onConfigurationChanged(newConfig)
val newDarkMode = newConfig.uiMode.and(Configuration.UI_MODE_NIGHT_MASK) == Configuration.UI_MODE_NIGHT_YES
if (darkMode != newDarkMode) {
@@ -775,12 +760,12 @@ class Godot private constructor(val context: Context) {
}
}
if (currentConfig.orientation != newConfig.orientation) {
if (orientation != newConfig.orientation) {
orientation = newConfig.orientation
runOnRenderThread {
GodotLib.onScreenRotationChange(newConfig.orientation)
GodotLib.onOrientationChange(orientation)
}
}
currentConfig = newConfig
}
/**
@@ -790,10 +775,8 @@ class Godot private constructor(val context: Context) {
for (plugin in pluginRegistry.allPlugins) {
plugin.onMainActivityResult(requestCode, resultCode, data)
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
runOnRenderThread {
FilePicker.handleActivityResult(context, requestCode, resultCode, data)
}
runOnRenderThread {
FilePicker.handleActivityResult(context, requestCode, resultCode, data)
}
}
@@ -821,7 +804,7 @@ class Godot private constructor(val context: Context) {
/**
* Invoked on the render thread when the Godot setup is complete.
*/
private fun onGodotSetupCompleted() {
internal fun onGodotSetupCompleted() {
Log.v(TAG, "OnGodotSetupCompleted")
// These properties are defined after Godot setup completion, so we retrieve them here.
@@ -832,7 +815,7 @@ class Godot private constructor(val context: Context) {
val scrollDeadzoneDisabled = java.lang.Boolean.parseBoolean(GodotLib.getGlobal("input_devices/pointing/android/disable_scroll_deadzone"))
runOnHostThread {
renderView?.inputHandler?.apply {
godotInputHandler.apply {
enableLongPress(longPressEnabled)
enablePanningAndScalingGestures(panScaleEnabled)
setOverrideVolumeButtons(overrideVolumeButtons)
@@ -854,9 +837,9 @@ class Godot private constructor(val context: Context) {
/**
* Invoked on the render thread when the Godot main loop has started.
*/
private fun onGodotMainLoopStarted() {
internal fun onGodotMainLoopStarted() {
Log.v(TAG, "OnGodotMainLoopStarted")
godotMainLoopStarted.set(true)
_runStatus.set(RunStatus.STARTED)
accelerometerEnabled.set(java.lang.Boolean.parseBoolean(GodotLib.getGlobal("input_devices/sensors/enable_accelerometer")))
gravityEnabled.set(java.lang.Boolean.parseBoolean(GodotLib.getGlobal("input_devices/sensors/enable_gravity")))
@@ -876,15 +859,16 @@ class Godot private constructor(val context: Context) {
/**
* Invoked on the render thread when the engine is about to terminate.
*/
@Keep
private fun onGodotTerminating() {
internal fun onGodotTerminating() {
Log.v(TAG, "OnGodotTerminating")
_runStatus.set(RunStatus.TERMINATING)
for (plugin in pluginRegistry.allPlugins) {
plugin.onGodotTerminating()
}
runOnTerminate.get()?.run()
}
private fun restart() {
primaryHost?.onGodotRestartRequested(this)
}
fun alert(
@StringRes messageResId: Int,
@@ -896,7 +880,6 @@ class Godot private constructor(val context: Context) {
}
@JvmOverloads
@Keep
fun alert(message: String, title: String, okCallback: Runnable? = null) {
val activity = getActivity() ?: return
runOnHostThread {
@@ -904,7 +887,7 @@ class Godot private constructor(val context: Context) {
builder.setMessage(message).setTitle(title)
builder.setPositiveButton(
R.string.dialog_ok
) { dialog: DialogInterface, id: Int ->
) { dialog: DialogInterface, _: Int ->
okCallback?.run()
dialog.cancel()
}
@@ -929,43 +912,25 @@ class Godot private constructor(val context: Context) {
primaryHost?.runOnHostThread(action)
}
/**
* Returns true if the call is being made on the Ui thread.
/**
* Returns the native rendering driver.
*/
private fun isOnUiThread() = Looper.myLooper() == Looper.getMainLooper()
private fun getNativeRenderer(): String {
val rendererInfo = GodotLib.getRendererInfo(meetsVulkanRequirements(context.packageManager))
val renderingDriverChosen = rendererInfo[0]
val renderingDriverOriginal = rendererInfo[1]
val renderingMethod = rendererInfo[2]
val renderingDriverSource = rendererInfo[3]
val renderingMethodSource = rendererInfo[4]
Log.d(TAG, """renderingDevice: $renderingDriverChosen (${renderingDriverSource})
renderer: $renderingMethod (${renderingMethodSource})""")
/**
* Returns true if `Vulkan` is used for rendering.
*/
private fun usesVulkan(): Boolean {
val rendererInfo = GodotLib.getRendererInfo()
var renderingDeviceSource = "ProjectSettings"
var renderingDevice = rendererInfo[0]
var rendererSource = "ProjectSettings"
var renderer = rendererInfo[1]
val cmdline = commandLine
var index = cmdline.indexOf("--rendering-method")
if (index > -1 && cmdline.size > index + 1) {
rendererSource = "CommandLine"
renderer = cmdline.get(index + 1)
if (renderingDriverOriginal == "vulkan" && renderingDriverChosen == "") {
// Throw the exception for the case where Vulkan failed to create and no fallback was available.
throw IllegalStateException(context.getString(R.string.error_missing_vulkan_requirements_message))
}
index = cmdline.indexOf("--rendering-driver")
if (index > -1 && cmdline.size > index + 1) {
renderingDeviceSource = "CommandLine"
renderingDevice = cmdline.get(index + 1)
}
val result = ("forward_plus" == renderer || "mobile" == renderer) && "vulkan" == renderingDevice
Log.d(TAG, """usesVulkan(): ${result}
renderingDevice: ${renderingDevice} (${renderingDeviceSource})
renderer: ${renderer} (${rendererSource})""")
return result
}
/**
* Returns true if can fallback to OpenGL.
*/
private fun canFallbackToOpenGL(): Boolean {
return java.lang.Boolean.parseBoolean(GodotLib.getGlobal("rendering/rendering_device/fallback_to_opengl3"))
return renderingDriverChosen
}
/**
@@ -976,15 +941,15 @@ class Godot private constructor(val context: Context) {
return false
}
if (!packageManager.hasSystemFeature(PackageManager.FEATURE_VULKAN_HARDWARE_LEVEL, 1)) {
// Optional requirements.. log as warning if missing
// Optional requirements log as warning if missing
Log.w(TAG, "The vulkan hardware level does not meet the minimum requirement: 1")
}
// Check for api version 1.0
return packageManager.hasSystemFeature(PackageManager.FEATURE_VULKAN_HARDWARE_VERSION, 0x400003)
// Check for api version 1.1
return packageManager.hasSystemFeature(PackageManager.FEATURE_VULKAN_HARDWARE_VERSION, 0x401000)
}
private fun setKeepScreenOn(enabled: Boolean) {
internal fun setKeepScreenOn(enabled: Boolean) {
runOnHostThread {
if (enabled) {
getActivity()?.window?.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
@@ -994,22 +959,6 @@ class Godot private constructor(val context: Context) {
}
}
/**
* Returns true if dark mode is supported, false otherwise.
*/
@Keep
private fun isDarkModeSupported(): Boolean {
return context.resources?.configuration?.uiMode?.and(Configuration.UI_MODE_NIGHT_MASK) != Configuration.UI_MODE_NIGHT_UNDEFINED
}
/**
* Returns true if dark mode is supported and enabled, false otherwise.
*/
@Keep
private fun isDarkMode(): Boolean {
return darkMode
}
@Keep
fun hasClipboard(): Boolean {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P || Looper.getMainLooper().thread == Thread.currentThread()) {
@@ -1039,51 +988,6 @@ class Godot private constructor(val context: Context) {
}
}
@Keep
private fun showFilePicker(currentDirectory: String, filename: String, fileMode: Int, filters: Array<String>) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
FilePicker.showFilePicker(context, getActivity(), currentDirectory, filename, fileMode, filters)
}
}
/**
* This method shows a dialog with multiple buttons.
*
* @param title The title of the dialog.
* @param message The message displayed in the dialog.
* @param buttons An array of button labels to display.
*/
@Keep
private fun showDialog(title: String, message: String, buttons: Array<String>) {
getActivity()?.let { DialogUtils.showDialog(it, title, message, buttons) }
}
/**
* This method shows a dialog with a text input field, allowing the user to input text.
*
* @param title The title of the input dialog.
* @param message The message displayed in the input dialog.
* @param existingText The existing text that will be pre-filled in the input field.
*/
@Keep
private fun showInputDialog(title: String, message: String, existingText: String) {
getActivity()?.let { DialogUtils.showInputDialog(it, title, message, existingText) }
}
@Keep
private fun getAccentColor(): Int {
val value = TypedValue()
context.theme.resolveAttribute(android.R.attr.colorAccent, value, true)
return value.data
}
@Keep
private fun getBaseColor(): Int {
val value = TypedValue()
context.theme.resolveAttribute(android.R.attr.colorBackground, value, true)
return value.data
}
/**
* Destroys the Godot Engine and kill the process it's running in.
*/
@@ -1107,8 +1011,7 @@ class Godot private constructor(val context: Context) {
}
}
@Keep
private fun forceQuit(instanceId: Int): Boolean {
internal fun forceQuit(instanceId: Int): Boolean {
primaryHost?.let {
if (instanceId == 0) {
it.onGodotForceQuit(this)
@@ -1116,7 +1019,8 @@ class Godot private constructor(val context: Context) {
} else {
return it.onGodotForceQuit(instanceId)
}
} ?: return false
}
return false
}
fun onBackPressed() {
@@ -1126,50 +1030,6 @@ class Godot private constructor(val context: Context) {
runOnRenderThread { GodotLib.back() }
}
/**
* Used by the native code (java_godot_wrapper.h) to vibrate the device.
* @param durationMs
*/
@SuppressLint("MissingPermission")
@Keep
private fun vibrate(durationMs: Int, amplitude: Int) {
if (durationMs > 0 && requestPermission("VIBRATE")) {
try {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
if (amplitude <= -1) {
vibratorService?.vibrate(
VibrationEffect.createOneShot(
durationMs.toLong(),
VibrationEffect.DEFAULT_AMPLITUDE
)
)
} else {
vibratorService?.vibrate(
VibrationEffect.createOneShot(
durationMs.toLong(),
amplitude
)
)
}
} else {
// deprecated in API 26
vibratorService?.vibrate(durationMs.toLong())
}
} catch (e: SecurityException) {
Log.w(TAG, "SecurityException: VIBRATE permission not found. Make sure it is declared in the manifest or enabled in the export preset.")
}
}
}
/**
* Used by the native code (java_godot_wrapper.h) to access the input fallback mapping.
* @return The input fallback mapping for the current XR mode.
*/
@Keep
private fun getInputFallbackMapping(): String? {
return xrMode.inputFallbackMapping
}
fun requestPermission(name: String?): Boolean {
val activity = getActivity() ?: return false
return requestPermission(name, activity)
@@ -1202,182 +1062,4 @@ class Godot private constructor(val context: Context) {
fun hasFeature(feature: String): Boolean {
return GodotLib.hasFeature(feature)
}
/**
* Internal method used to query whether the host or the registered plugins supports a given feature.
*
* This is invoked by the native code, and should not be confused with [hasFeature] which is the Android version of
* https://docs.godotengine.org/en/stable/classes/class_os.html#class-os-method-has-feature
*/
@Keep
private fun checkInternalFeatureSupport(feature: String): Boolean {
if (primaryHost?.supportsFeature(feature) == true) {
return true
}
for (plugin in pluginRegistry.allPlugins) {
if (plugin.supportsFeature(feature)) {
return true
}
}
return false
}
/**
* Get the list of gdextension modules to register.
*/
@Keep
private fun getGDExtensionConfigFiles(): Array<String> {
val configFiles = mutableSetOf<String>()
for (plugin in pluginRegistry.allPlugins) {
configFiles.addAll(plugin.pluginGDExtensionLibrariesPaths)
}
return configFiles.toTypedArray()
}
@Keep
private fun getCACertificates(): String {
return GodotNetUtils.getCACertificates()
}
private fun obbIsCorrupted(f: String, mainPackMd5: String): Boolean {
return try {
val fis: InputStream = FileInputStream(f)
// Create MD5 Hash
val buffer = ByteArray(16384)
val complete = MessageDigest.getInstance("MD5")
var numRead: Int
do {
numRead = fis.read(buffer)
if (numRead > 0) {
complete.update(buffer, 0, numRead)
}
} while (numRead != -1)
fis.close()
val messageDigest = complete.digest()
// Create Hex String
val hexString = StringBuilder()
for (b in messageDigest) {
var s = Integer.toHexString(0xFF and b.toInt())
if (s.length == 1) {
s = "0$s"
}
hexString.append(s)
}
val md5str = hexString.toString()
md5str != mainPackMd5
} catch (e: java.lang.Exception) {
e.printStackTrace()
true
}
}
@Keep
private fun initInputDevices() {
godotInputHandler.initInputDevices()
}
@Keep
private fun createNewGodotInstance(args: Array<String>): Int {
return primaryHost?.onNewGodotInstanceRequested(args) ?: -1
}
@Keep
private fun nativeBeginBenchmarkMeasure(scope: String, label: String) {
beginBenchmarkMeasure(scope, label)
}
@Keep
private fun nativeEndBenchmarkMeasure(scope: String, label: String) {
endBenchmarkMeasure(scope, label)
}
@Keep
private fun nativeDumpBenchmark(benchmarkFile: String) {
dumpBenchmark(fileAccessHandler, benchmarkFile)
}
@Keep
private fun nativeSignApk(inputPath: String,
outputPath: String,
keystorePath: String,
keystoreUser: String,
keystorePassword: String): Int {
val signResult = primaryHost?.signApk(inputPath, outputPath, keystorePath, keystoreUser, keystorePassword) ?: Error.ERR_UNAVAILABLE
return signResult.toNativeValue()
}
@Keep
private fun nativeVerifyApk(apkPath: String): Int {
val verifyResult = primaryHost?.verifyApk(apkPath) ?: Error.ERR_UNAVAILABLE
return verifyResult.toNativeValue()
}
@Keep
private fun nativeOnEditorWorkspaceSelected(workspace: String) {
primaryHost?.onEditorWorkspaceSelected(workspace)
}
@Keep
private fun nativeBuildEnvConnect(callback: GodotCallable): Boolean {
try {
val buildProvider = primaryHost?.getBuildProvider()
return buildProvider?.buildEnvConnect(callback) ?: false
} catch (e: Exception) {
Log.e(TAG, "Unable to connect to build environment", e)
return false
}
}
@Keep
private fun nativeBuildEnvDisconnect() {
try {
val buildProvider = primaryHost?.getBuildProvider()
buildProvider?.buildEnvDisconnect()
} catch (e: Exception) {
Log.e(TAG, "Unable to disconnect from build environment", e)
}
}
@Keep
private fun nativeBuildEnvExecute(buildTool: String, arguments: Array<String>, projectPath: String, buildDir: String, outputCallback: GodotCallable, resultCallback: GodotCallable): Int {
try {
val buildProvider = primaryHost?.getBuildProvider()
return buildProvider?.buildEnvExecute(
buildTool,
arguments,
projectPath,
buildDir,
outputCallback,
resultCallback
) ?: -1
} catch (e: Exception) {
Log.e(TAG, "Unable to execute Gradle command in build environment", e);
return -1
}
}
@Keep
private fun nativeBuildEnvCancel(jobId: Int) {
try {
val buildProvider = primaryHost?.getBuildProvider()
buildProvider?.buildEnvCancel(jobId)
} catch (e: Exception) {
Log.e(TAG, "Unable to cancel command in build environment", e)
}
}
@Keep
private fun nativeBuildEnvCleanProject(projectPath: String, buildDir: String, callback: GodotCallable) {
try {
val buildProvider = primaryHost?.getBuildProvider()
buildProvider?.buildEnvCleanProject(projectPath, buildDir, callback)
} catch(e: Exception) {
Log.e(TAG, "Unable to clean project in build environment", e)
}
}
}
@@ -31,25 +31,34 @@
package org.godotengine.godot
import android.app.Activity
import android.app.PictureInPictureParams
import android.content.ComponentName
import android.content.Intent
import android.content.pm.PackageManager
import android.graphics.Rect
import android.os.Build
import android.os.Bundle
import android.util.Log
import android.util.Rational
import android.view.View
import androidx.activity.addCallback
import androidx.annotation.CallSuper
import androidx.annotation.LayoutRes
import androidx.fragment.app.FragmentActivity
import org.godotengine.godot.feature.PictureInPictureProvider
import org.godotengine.godot.utils.CommandLineFileParser
import org.godotengine.godot.utils.PermissionsUtil
import org.godotengine.godot.utils.ProcessPhoenix
import java.util.concurrent.atomic.AtomicBoolean
import java.util.concurrent.atomic.AtomicReference
/**
* Base abstract activity for Android apps intending to use Godot as the primary screen.
*
* Also a reference implementation for how to setup and use the [GodotFragment] fragment
* This is also a reference implementation for how to set up and use the [GodotFragment] fragment
* within an Android app.
*/
abstract class GodotActivity : FragmentActivity(), GodotHost {
abstract class GodotActivity : FragmentActivity(), GodotHost, PictureInPictureProvider {
companion object {
private val TAG = GodotActivity::class.java.simpleName
@@ -62,10 +71,22 @@ abstract class GodotActivity : FragmentActivity(), GodotHost {
// This window must not match those in BaseGodotEditor.RUN_GAME_INFO etc
@JvmStatic
private final val DEFAULT_WINDOW_ID = 664;
private val DEFAULT_WINDOW_ID = 664
}
/**
* Set to true if the activity should automatically enter picture-in-picture when put in the background.
*/
private val pipAspectRatio = AtomicReference<Rational>()
private val autoEnterPiP = AtomicBoolean(false)
private val gameViewSourceRectHint = Rect()
private val commandLineParams = ArrayList<String>()
// The bounds of what the aspect ratio can be are between 2.39:1 and 1:2.39 (inclusive).
// If aspect ratio does not fall between these values, app will crash.
private val minPiPRatio = Rational(100, 239)
private val maxPiPRatio = Rational(239, 100)
/**
* Interaction with the [Godot] object is delegated to the [GodotFragment] class.
*/
@@ -119,6 +140,9 @@ abstract class GodotActivity : FragmentActivity(), GodotHost {
setContentView(getGodotAppLayout())
// Register `OnBackPressedCallback` for the Godot fragment.
onBackPressedDispatcher.addCallback { godotFragment?.onBackPressed() }
handleStartIntent(intent, true)
val currentFragment = supportFragmentManager.findFragmentById(R.id.godot_fragment_container)
@@ -139,6 +163,13 @@ abstract class GodotActivity : FragmentActivity(), GodotHost {
.setPrimaryNavigationFragment(godotFragment)
.commitNowAllowingStateLoss()
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val gameView = findViewById<View>(R.id.godot_fragment_container)
gameView?.addOnLayoutChangeListener { v, left, top, right, bottom, oldLeft, oldTop, oldRight, oldBottom ->
gameView.getGlobalVisibleRect(gameViewSourceRectHint)
}
}
}
override fun onNewGodotInstanceRequested(args: Array<String>): Int {
@@ -149,7 +180,7 @@ abstract class GodotActivity : FragmentActivity(), GodotHost {
.putExtra(EXTRA_COMMAND_LINE_PARAMS, args)
triggerRebirth(null, intent)
// fake 'process' id returned by create_instance() etc
return DEFAULT_WINDOW_ID;
return DEFAULT_WINDOW_ID
}
protected fun triggerRebirth(bundle: Bundle?, intent: Intent) {
@@ -167,6 +198,15 @@ abstract class GodotActivity : FragmentActivity(), GodotHost {
super.onDestroy()
}
override fun onStop() {
super.onStop()
if (isInPictureInPictureMode && !isFinishing) {
// We get in this state when PiP is closed, so we terminate the activity.
finish()
}
}
override fun onGodotForceQuit(instance: Godot) {
runOnUiThread { terminateGodotInstance(instance) }
}
@@ -196,13 +236,31 @@ abstract class GodotActivity : FragmentActivity(), GodotHost {
}
}
override fun onGodotSetupCompleted() {
super.onGodotSetupCompleted()
if (isPiPEnabled()) {
try {
// Update the aspect ratio for picture-in-picture mode.
val viewportWidth = Integer.parseInt(GodotLib.getGlobal("display/window/size/viewport_width"))
val viewportHeight = Integer.parseInt(GodotLib.getGlobal("display/window/size/viewport_height"))
pipAspectRatio.set(Rational(viewportWidth, viewportHeight))
} catch (e: NumberFormatException) {
Log.w(TAG, "Unable to parse viewport dimensions.", e)
}
runOnHostThread { updatePiPParams() }
}
}
override fun onNewIntent(newIntent: Intent) {
intent = sanitizeLaunchIntent(newIntent)
super.onNewIntent(intent)
handleStartIntent(intent, false)
}
private fun handleStartIntent(intent: Intent, newLaunch: Boolean) {
@CallSuper
protected open fun handleStartIntent(intent: Intent, newLaunch: Boolean) {
if (!newLaunch) {
val newLaunchRequested = intent.getBooleanExtra(EXTRA_NEW_LAUNCH, false)
if (newLaunchRequested) {
@@ -235,10 +293,6 @@ abstract class GodotActivity : FragmentActivity(), GodotHost {
}
}
override fun onBackPressed() {
godotFragment?.onBackPressed() ?: super.onBackPressed()
}
override fun getActivity(): Activity? {
return this
}
@@ -256,4 +310,65 @@ abstract class GodotActivity : FragmentActivity(), GodotHost {
@CallSuper
override fun getCommandLine(): MutableList<String> = commandLineParams
override fun onPictureInPictureModeChanged(isInPictureInPictureMode: Boolean) {
super.onPictureInPictureModeChanged(isInPictureInPictureMode)
godot?.onPictureInPictureModeChanged(isInPictureInPictureMode)
}
/**
* Returns true if picture-in-picture (PiP) mode is supported.
*/
override fun isPiPModeSupported() = isPiPEnabled() && packageManager.hasSystemFeature(PackageManager.FEATURE_PICTURE_IN_PICTURE)
/**
* Returns true if the current activity has enabled picture-in-picture in its manifest declaration using
* 'android:supportsPictureInPicture="true"'
*/
protected open fun isPiPEnabled() = false
fun updatePiPParams(enableAutoEnter: Boolean = autoEnterPiP.get(), aspectRatio: Rational? = pipAspectRatio.get()) {
val fixedAspectRatio = aspectRatio?.let {
if (it < minPiPRatio || it > maxPiPRatio) {
Log.w(TAG, "The bounds of the aspect ratio must be between 2.39:1 and 1:2.39 (inclusive). Coercing to valid range.")
it.coerceIn(minPiPRatio, maxPiPRatio)
} else {
it
}
}
if (isPiPModeSupported()) {
autoEnterPiP.set(enableAutoEnter)
pipAspectRatio.set(fixedAspectRatio)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val builder = PictureInPictureParams.Builder()
.setSourceRectHint(gameViewSourceRectHint)
.setAspectRatio(fixedAspectRatio)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
builder.setSeamlessResizeEnabled(false)
.setAutoEnterEnabled(enableAutoEnter)
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
builder.setExpandedAspectRatio(fixedAspectRatio)
}
setPictureInPictureParams(builder.build())
}
}
}
override fun enterPiPMode() {
if (isPiPModeSupported()) {
updatePiPParams()
Log.v(TAG, "Entering PiP mode")
enterPictureInPictureMode()
}
}
override fun onUserLeaveHint() {
if (autoEnterPiP.get()) {
enterPiPMode()
}
}
}
@@ -1,85 +0,0 @@
/**************************************************************************/
/* GodotDownloaderService.java */
/**************************************************************************/
/* This file is part of: */
/* GODOT ENGINE */
/* https://godotengine.org */
/**************************************************************************/
/* Copyright (c) 2014-present Godot Engine contributors (see AUTHORS.md). */
/* Copyright (c) 2007-2014 Juan Linietsky, Ariel Manzur. */
/* */
/* Permission is hereby granted, free of charge, to any person obtaining */
/* a copy of this software and associated documentation files (the */
/* "Software"), to deal in the Software without restriction, including */
/* without limitation the rights to use, copy, modify, merge, publish, */
/* distribute, sublicense, and/or sell copies of the Software, and to */
/* permit persons to whom the Software is furnished to do so, subject to */
/* the following conditions: */
/* */
/* The above copyright notice and this permission notice shall be */
/* included in all copies or substantial portions of the Software. */
/* */
/* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, */
/* EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF */
/* MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. */
/* IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY */
/* CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, */
/* TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE */
/* SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */
/**************************************************************************/
package org.godotengine.godot;
import android.content.Context;
import android.content.SharedPreferences;
import android.util.Log;
import com.google.android.vending.expansion.downloader.impl.DownloaderService;
/**
* This class demonstrates the minimal client implementation of the
* DownloaderService from the Downloader library.
*/
public class GodotDownloaderService extends DownloaderService {
// stuff for LVL -- MODIFY FOR YOUR APPLICATION!
private static final String BASE64_PUBLIC_KEY = "REPLACE THIS WITH YOUR PUBLIC KEY";
// used by the preference obfuscater
private static final byte[] SALT = new byte[] {
1, 43, -12, -1, 54, 98,
-100, -12, 43, 2, -8, -4, 9, 5, -106, -108, -33, 45, -1, 84
};
/**
* This public key comes from your Android Market publisher account, and it
* used by the LVL to validate responses from Market on your behalf.
*/
@Override
public String getPublicKey() {
SharedPreferences prefs = getApplicationContext().getSharedPreferences("app_data_keys", Context.MODE_PRIVATE);
Log.d("GODOT", "getting public key:" + prefs.getString("store_public_key", null));
return prefs.getString("store_public_key", null);
//return BASE64_PUBLIC_KEY;
}
/**
* This is used by the preference obfuscater to make sure that your
* obfuscated preferences are different than the ones used by other
* applications.
*/
@Override
public byte[] getSALT() {
return SALT;
}
/**
* Fill this in with the class name for your alarm receiver. We do this
* because receivers must be unique across all of Android (it's a good idea
* to make sure that your receiver is in your unique package)
*/
@Override
public String getAlarmReceiverClassName() {
Log.d("GODOT", "getAlarmReceiverClassName()");
return GodotDownloaderAlarmReceiver.class.getName();
}
}
@@ -34,80 +34,38 @@ import org.godotengine.godot.error.Error;
import org.godotengine.godot.plugin.GodotPlugin;
import org.godotengine.godot.utils.BenchmarkUtils;
import android.app.Activity;
import android.app.PendingIntent;
import android.content.Context;
import android.content.Intent;
import android.content.pm.PackageManager;
import android.content.res.Configuration;
import android.os.Bundle;
import android.os.Messenger;
import android.text.TextUtils;
import android.util.Log;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.Button;
import android.widget.FrameLayout;
import android.widget.ProgressBar;
import android.widget.TextView;
import androidx.annotation.CallSuper;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.fragment.app.Fragment;
import com.google.android.vending.expansion.downloader.DownloadProgressInfo;
import com.google.android.vending.expansion.downloader.DownloaderClientMarshaller;
import com.google.android.vending.expansion.downloader.DownloaderServiceMarshaller;
import com.google.android.vending.expansion.downloader.Helpers;
import com.google.android.vending.expansion.downloader.IDownloaderClient;
import com.google.android.vending.expansion.downloader.IDownloaderService;
import com.google.android.vending.expansion.downloader.IStub;
import java.util.Collections;
import java.util.List;
import java.util.Locale;
import java.util.Set;
/**
* Base fragment for Android apps intending to use Godot for part of the app's UI.
*/
public class GodotFragment extends Fragment implements IDownloaderClient, GodotHost {
public class GodotFragment extends Fragment implements GodotHost {
private static final String TAG = GodotFragment.class.getSimpleName();
private IStub mDownloaderClientStub;
private TextView mStatusText;
private TextView mProgressFraction;
private TextView mProgressPercent;
private TextView mAverageSpeed;
private TextView mTimeRemaining;
private ProgressBar mPB;
private View mDashboard;
private View mCellMessage;
private Button mPauseButton;
private FrameLayout godotContainerLayout;
private int mState;
@Nullable
private GodotHost parentHost;
private Godot godot;
private void setState(int newState) {
if (mState != newState) {
mState = newState;
mStatusText.setText(Helpers.getDownloaderStringResourceIDFromState(newState));
}
}
private void setButtonPausedState(boolean paused) {
int stringResourceID = paused ? R.string.text_button_resume : R.string.text_button_pause;
mPauseButton.setText(stringResourceID);
}
@Override
public Godot getGodot() {
return godot;
@@ -155,12 +113,6 @@ public class GodotFragment extends Fragment implements IDownloaderClient, GodotH
godot.onRequestPermissionsResult(requestCode, permissions, grantResults);
}
@Override
public void onServiceConnected(Messenger m) {
IDownloaderService remoteService = DownloaderServiceMarshaller.CreateProxy(m);
remoteService.onClientUpdated(mDownloaderClientStub.getMessenger());
}
@Override
public void onCreate(Bundle icicle) {
BenchmarkUtils.beginBenchmarkMeasure("Startup", "GodotFragment::onCreate");
@@ -186,57 +138,17 @@ public class GodotFragment extends Fragment implements IDownloaderClient, GodotH
if (godotContainerLayout == null) {
throw new IllegalStateException("Unable to initialize engine render view");
}
} catch (IllegalStateException e) {
} catch (Exception e) {
Log.e(TAG, "Engine initialization failed", e);
final String errorMessage = TextUtils.isEmpty(e.getMessage())
? getString(R.string.error_engine_setup_message)
: e.getMessage();
godot.alert(errorMessage, getString(R.string.text_error_title), godot::destroyAndKillProcess);
} catch (IllegalArgumentException ignored) {
final Activity activity = getActivity();
Intent notifierIntent = new Intent(activity, activity.getClass());
notifierIntent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TOP);
PendingIntent pendingIntent = PendingIntent.getActivity(activity, 0,
notifierIntent, PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE);
int startResult;
try {
startResult = DownloaderClientMarshaller.startDownloadServiceIfRequired(getContext(), pendingIntent, GodotDownloaderService.class);
if (startResult != DownloaderClientMarshaller.NO_DOWNLOAD_REQUIRED) {
// This is where you do set up to display the download
// progress (next step in onCreateView)
mDownloaderClientStub = DownloaderClientMarshaller.CreateStub(this, GodotDownloaderService.class);
return;
}
// Restart engine initialization
performEngineInitialization();
} catch (PackageManager.NameNotFoundException e) {
Log.e(TAG, "Unable to start download service", e);
}
}
}
@Override
public View onCreateView(@NonNull LayoutInflater inflater, ViewGroup container, Bundle icicle) {
if (mDownloaderClientStub != null) {
View downloadingExpansionView =
inflater.inflate(R.layout.downloading_expansion, container, false);
mPB = (ProgressBar)downloadingExpansionView.findViewById(R.id.progressBar);
mStatusText = (TextView)downloadingExpansionView.findViewById(R.id.statusText);
mProgressFraction = (TextView)downloadingExpansionView.findViewById(R.id.progressAsFraction);
mProgressPercent = (TextView)downloadingExpansionView.findViewById(R.id.progressAsPercentage);
mAverageSpeed = (TextView)downloadingExpansionView.findViewById(R.id.progressAverageSpeed);
mTimeRemaining = (TextView)downloadingExpansionView.findViewById(R.id.progressTimeRemaining);
mDashboard = downloadingExpansionView.findViewById(R.id.downloaderDashboard);
mCellMessage = downloadingExpansionView.findViewById(R.id.approveCellular);
mPauseButton = (Button)downloadingExpansionView.findViewById(R.id.pauseButton);
return downloadingExpansionView;
}
if (godotContainerLayout != null && godotContainerLayout.getParent() != null) {
Log.w(TAG, "Godot container layout already has a parent, removing it.");
((ViewGroup)godotContainerLayout.getParent()).removeView(godotContainerLayout);
@@ -259,53 +171,24 @@ public class GodotFragment extends Fragment implements IDownloaderClient, GodotH
@Override
public void onPause() {
super.onPause();
if (!godot.isInitialized()) {
if (null != mDownloaderClientStub) {
mDownloaderClientStub.disconnect(getActivity());
}
return;
}
godot.onPause(this);
}
@Override
public void onStop() {
super.onStop();
if (!godot.isInitialized()) {
if (null != mDownloaderClientStub) {
mDownloaderClientStub.disconnect(getActivity());
}
return;
}
godot.onStop(this);
}
@Override
public void onStart() {
super.onStart();
if (!godot.isInitialized()) {
if (null != mDownloaderClientStub) {
mDownloaderClientStub.connect(getActivity());
}
return;
}
godot.onStart(this);
}
@Override
public void onResume() {
super.onResume();
if (!godot.isInitialized()) {
if (null != mDownloaderClientStub) {
mDownloaderClientStub.connect(getActivity());
}
return;
}
godot.onResume(this);
}
@@ -313,100 +196,6 @@ public class GodotFragment extends Fragment implements IDownloaderClient, GodotH
godot.onBackPressed();
}
/**
* The download state should trigger changes in the UI --- it may be useful
* to show the state as being indeterminate at times. This sample can be
* considered a guideline.
*/
@Override
public void onDownloadStateChanged(int newState) {
setState(newState);
boolean showDashboard = true;
boolean showCellMessage = false;
boolean paused;
boolean indeterminate;
switch (newState) {
case IDownloaderClient.STATE_IDLE:
// STATE_IDLE means the service is listening, so it's
// safe to start making remote service calls.
paused = false;
indeterminate = true;
break;
case IDownloaderClient.STATE_CONNECTING:
case IDownloaderClient.STATE_FETCHING_URL:
showDashboard = true;
paused = false;
indeterminate = true;
break;
case IDownloaderClient.STATE_DOWNLOADING:
paused = false;
showDashboard = true;
indeterminate = false;
break;
case IDownloaderClient.STATE_FAILED_CANCELED:
case IDownloaderClient.STATE_FAILED:
case IDownloaderClient.STATE_FAILED_FETCHING_URL:
case IDownloaderClient.STATE_FAILED_UNLICENSED:
paused = true;
showDashboard = false;
indeterminate = false;
break;
case IDownloaderClient.STATE_PAUSED_NEED_CELLULAR_PERMISSION:
case IDownloaderClient.STATE_PAUSED_WIFI_DISABLED_NEED_CELLULAR_PERMISSION:
showDashboard = false;
paused = true;
indeterminate = false;
showCellMessage = true;
break;
case IDownloaderClient.STATE_PAUSED_BY_REQUEST:
paused = true;
indeterminate = false;
break;
case IDownloaderClient.STATE_PAUSED_ROAMING:
case IDownloaderClient.STATE_PAUSED_SDCARD_UNAVAILABLE:
paused = true;
indeterminate = false;
break;
case IDownloaderClient.STATE_COMPLETED:
showDashboard = false;
paused = false;
indeterminate = false;
performEngineInitialization();
return;
default:
paused = true;
indeterminate = true;
showDashboard = true;
}
int newDashboardVisibility = showDashboard ? View.VISIBLE : View.GONE;
if (mDashboard.getVisibility() != newDashboardVisibility) {
mDashboard.setVisibility(newDashboardVisibility);
}
int cellMessageVisibility = showCellMessage ? View.VISIBLE : View.GONE;
if (mCellMessage.getVisibility() != cellMessageVisibility) {
mCellMessage.setVisibility(cellMessageVisibility);
}
mPB.setIndeterminate(indeterminate);
setButtonPausedState(paused);
}
@Override
public void onDownloadProgress(DownloadProgressInfo progress) {
mAverageSpeed.setText(getString(R.string.kilobytes_per_second,
Helpers.getSpeedString(progress.mCurrentSpeed)));
mTimeRemaining.setText(getString(R.string.time_remaining,
Helpers.getTimeRemaining(progress.mTimeRemaining)));
mPB.setMax((int)(progress.mOverallTotal >> 8));
mPB.setProgress((int)(progress.mOverallProgress >> 8));
mProgressPercent.setText(String.format(Locale.ENGLISH, "%d %%", progress.mOverallProgress * 100 / progress.mOverallTotal));
mProgressFraction.setText(Helpers.getDownloadProgressString(progress.mOverallProgress,
progress.mOverallTotal));
}
@CallSuper
@Override
public List<String> getCommandLine() {
@@ -496,6 +285,13 @@ public class GodotFragment extends Fragment implements IDownloaderClient, GodotH
}
}
@Override
public void onDistractionFreeModeChanged(Boolean enabled) {
if (parentHost != null) {
parentHost.onDistractionFreeModeChanged(enabled);
}
}
@Override
public BuildProvider getBuildProvider() {
if (parentHost != null) {
@@ -153,6 +153,11 @@ public interface GodotHost {
*/
default void onEditorWorkspaceSelected(String workspace) {}
/**
* Triggered when the editor's distraction-free mode changes.
*/
default void onDistractionFreeModeChanged(Boolean enabled) {}
/**
* Runs the specified action on a host provided thread.
*/
@@ -33,6 +33,7 @@ package org.godotengine.godot;
import org.godotengine.godot.gl.GodotRenderer;
import org.godotengine.godot.io.directory.DirectoryAccessHandler;
import org.godotengine.godot.io.file.FileAccessHandler;
import org.godotengine.godot.nativeapi.GodotNativeBridge;
import org.godotengine.godot.tts.GodotTTS;
import org.godotengine.godot.utils.GodotNetUtils;
import org.godotengine.godot.variant.Callable;
@@ -56,13 +57,13 @@ public class GodotLib {
* Invoked on the main thread to initialize Godot native layer.
*/
public static native boolean initialize(
Godot p_instance,
AssetManager p_asset_manager,
GodotNativeBridge nativeBridge,
AssetManager assetManager,
GodotIO godotIO,
GodotNetUtils netUtils,
DirectoryAccessHandler directoryAccessHandler,
FileAccessHandler fileAccessHandler,
boolean use_apk_expansion);
boolean useApkExpansion);
/**
* Invoked on the main thread to clean up Godot native layer.
@@ -192,11 +193,14 @@ public class GodotLib {
/**
* Used to get info about the current rendering system.
*
* @return A String array with two elements:
* [0] Rendering driver name.
* [1] Rendering method.
* @return A String array with three elements:
* [0] Rendering driver name chosen for rendering.
* [1] Rendering driver name chosen before any fallbacks were applied.
* [2] Rendering method.
* [3] Source where the rendering driver was chosen from.
* [4] Source where the rendering method was chosen from.
*/
public static native String[] getRendererInfo();
public static native String[] getRendererInfo(boolean p_vulkan_requirements_met);
/**
* Used to access Godot's editor settings.
@@ -299,7 +303,7 @@ public class GodotLib {
* Invoked when the screen orientation changes.
* @param orientation the new screen orientation
*/
static native void onScreenRotationChange(int orientation);
static native void onOrientationChange(int orientation);
/**
* @return true if input must be dispatched from the render thread. If false, input is
@@ -317,4 +321,6 @@ public class GodotLib {
static native boolean isProjectManagerHint();
static native boolean hasFeature(String feature);
static native void onPictureInPictureModeChanged(boolean isInPictureInPictureMode);
}
@@ -38,4 +38,7 @@ package org.godotengine.godot.editor.utils
object EditorUtils {
@JvmStatic
external fun runScene(scene: String, sceneArgs: Array<String>)
@JvmStatic
external fun toggleTitleBar(visible: Boolean)
}
@@ -1,5 +1,5 @@
/**************************************************************************/
/* GodotDownloaderAlarmReceiver.java */
/* PictureInPictureProvider.kt */
/**************************************************************************/
/* This file is part of: */
/* GODOT ENGINE */
@@ -28,32 +28,14 @@
/* SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */
/**************************************************************************/
package org.godotengine.godot;
import android.content.BroadcastReceiver;
import android.content.Context;
import android.content.Intent;
import android.content.pm.PackageManager.NameNotFoundException;
import android.util.Log;
import com.google.android.vending.expansion.downloader.DownloaderClientMarshaller;
package org.godotengine.godot.feature
/**
* You should start your derived downloader class when this receiver gets the message
* from the alarm service using the provided service helper function within the
* DownloaderClientMarshaller. This class must be then registered in your AndroidManifest.xml
* file with a section like this:
* <receiver android:name=".GodotDownloaderAlarmReceiver"/>
* Provides APIs to enable picture-in-picture.
*/
public class GodotDownloaderAlarmReceiver extends BroadcastReceiver {
@Override
public void onReceive(Context context, Intent intent) {
Log.d("GODOT", "Alarma recivida");
try {
DownloaderClientMarshaller.startDownloadServiceIfRequired(context, intent, GodotDownloaderService.class);
} catch (NameNotFoundException e) {
e.printStackTrace();
Log.d("GODOT", "Exception: " + e.getClass().getName() + ":" + e.getMessage());
}
}
interface PictureInPictureProvider {
fun enterPiPMode()
fun isPiPModeSupported(): Boolean
}
@@ -57,7 +57,7 @@ public class GodotEditText extends EditText {
private final static int HANDLER_OPEN_IME_KEYBOARD = 2;
private final static int HANDLER_CLOSE_IME_KEYBOARD = 3;
// Enum must be kept up-to-date with DisplayServer::VirtualKeyboardType
// Enum must be kept up-to-date with DisplayServerEnums::VirtualKeyboardType
public enum VirtualKeyboardType {
KEYBOARD_TYPE_DEFAULT,
KEYBOARD_TYPE_MULTILINE,
@@ -57,6 +57,11 @@ internal class GodotGestureHandler(private val inputHandler: GodotInputHandler)
var scrollDeadzoneDisabled = false
/**
* Enable haptic feedback on long-press right-click
*/
var hapticFeedbackEnabled = false
private var nextDownIsDoubleTap = false
private var dragInProgress = false
private var scaleInProgress = false
@@ -80,6 +85,9 @@ internal class GodotGestureHandler(private val inputHandler: GodotInputHandler)
override fun onLongPress(event: MotionEvent) {
val toolType = GodotInputHandler.getEventToolType(event)
if (toolType != MotionEvent.TOOL_TYPE_MOUSE) {
if (hapticFeedbackEnabled) {
inputHandler.performHapticFeedback()
}
contextClickRouter(event)
}
}
@@ -47,6 +47,7 @@ import android.util.Log;
import android.util.SparseArray;
import android.util.SparseIntArray;
import android.view.GestureDetector;
import android.view.HapticFeedbackConstants;
import android.view.InputDevice;
import android.view.KeyEvent;
import android.view.MotionEvent;
@@ -133,6 +134,23 @@ public class GodotInputHandler implements InputManager.InputDeviceListener, Sens
this.godotGestureHandler.setScrollDeadzoneDisabled(disable);
}
/**
* Enable haptic feedback (vibration) when a long-press right-click is triggered.
*/
public void enableHapticFeedback(boolean enable) {
this.godotGestureHandler.setHapticFeedbackEnabled(enable);
}
/**
* Perform haptic feedback on the render view.
*/
void performHapticFeedback() {
GodotRenderView view = godot.getRenderView();
if (view != null) {
view.getView().performHapticFeedback(HapticFeedbackConstants.LONG_PRESS);
}
}
/**
* Enable multi-fingers pan & scale gestures. This is false by default.
* <p>
@@ -30,11 +30,10 @@
package org.godotengine.godot.io
import android.content.ContentResolver
import android.content.Context
import android.os.Build
import android.os.Environment
import androidx.core.net.toUri
import org.godotengine.godot.Godot
import java.io.File
import org.godotengine.godot.GodotLib
@@ -70,6 +69,7 @@ internal enum class StorageScope {
class Identifier(context: Context) {
companion object {
internal const val ACCESS_RESOURCES_PREFIX = "res://"
internal const val ASSETS_PREFIX = "assets://"
internal const val CONTENT_PREFIX = "content://"
}
@@ -102,6 +102,10 @@ internal enum class StorageScope {
return ASSETS
}
if (Godot.isTemplateBuild() && path.startsWith(ACCESS_RESOURCES_PREFIX)) {
return ASSETS
}
// If it's either content uri for a file, or starts with a tree uri.
if (path.startsWith(CONTENT_PREFIX)) {
return SAF
@@ -203,8 +203,10 @@ internal abstract class DataAccess {
abstract fun write(buffer: ByteBuffer): Boolean
fun seekFromEnd(positionFromEnd: Long) {
val positionFromBeginning = max(0, size() - positionFromEnd)
seek(positionFromBeginning)
val positionFromBeginning = size() + positionFromEnd
if (positionFromBeginning >= 0) {
seek(positionFromBeginning)
}
}
abstract class FileChannelDataAccess(private val filePath: String) : DataAccess() {
@@ -0,0 +1,435 @@
/**************************************************************************/
/* GodotNativeBridge.kt */
/**************************************************************************/
/* This file is part of: */
/* GODOT ENGINE */
/* https://godotengine.org */
/**************************************************************************/
/* Copyright (c) 2014-present Godot Engine contributors (see AUTHORS.md). */
/* Copyright (c) 2007-2014 Juan Linietsky, Ariel Manzur. */
/* */
/* Permission is hereby granted, free of charge, to any person obtaining */
/* a copy of this software and associated documentation files (the */
/* "Software"), to deal in the Software without restriction, including */
/* without limitation the rights to use, copy, modify, merge, publish, */
/* distribute, sublicense, and/or sell copies of the Software, and to */
/* permit persons to whom the Software is furnished to do so, subject to */
/* the following conditions: */
/* */
/* The above copyright notice and this permission notice shall be */
/* included in all copies or substantial portions of the Software. */
/* */
/* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, */
/* EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF */
/* MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. */
/* IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY */
/* CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, */
/* TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE */
/* SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */
/**************************************************************************/
package org.godotengine.godot.nativeapi
import android.annotation.SuppressLint
import android.app.AlertDialog
import android.content.Context
import android.content.DialogInterface
import android.content.Intent
import android.content.res.Configuration
import android.os.Build
import android.os.VibrationEffect
import android.os.Vibrator
import android.util.Log
import android.util.Rational
import android.util.TypedValue
import androidx.annotation.Keep
import androidx.core.net.toUri
import org.godotengine.godot.Godot
import org.godotengine.godot.GodotActivity
import org.godotengine.godot.R
import org.godotengine.godot.error.Error
import org.godotengine.godot.feature.PictureInPictureProvider
import org.godotengine.godot.io.FilePicker
import org.godotengine.godot.utils.DialogUtils
import org.godotengine.godot.utils.GodotNetUtils
import org.godotengine.godot.utils.beginBenchmarkMeasure
import org.godotengine.godot.utils.dumpBenchmark
import org.godotengine.godot.utils.endBenchmarkMeasure
import org.godotengine.godot.variant.Callable as GodotCallable
/**
* Holds and expose Godot apis to the native layer.
*
* All the methods in this class are accessed by the native code (java_godot_wrapper.h) and as such are kept private to
* not be accessible by the rest of the java/kotlin code.
*/
@Keep
internal class GodotNativeBridge(private val godot: Godot) {
companion object {
private val TAG = GodotNativeBridge::class.java.simpleName
}
private val vibratorService: Vibrator? by lazy { godot.context.getSystemService(Context.VIBRATOR_SERVICE) as? Vibrator }
/**
* Invoked on the render thread when the Godot setup is complete.
*/
private fun onGodotSetupCompleted() {
godot.onGodotSetupCompleted()
}
/**
* Invoked on the render thread when the Godot main loop has started.
*/
private fun onGodotMainLoopStarted() {
godot.onGodotMainLoopStarted()
}
/**
* Invoked on the render thread when the engine is about to terminate.
*/
private fun onGodotTerminating() = godot.onGodotTerminating()
/**
* Invoked from the render thread to toggle the immersive mode.
*/
private fun nativeEnableImmersiveMode(enabled: Boolean) {
godot.runOnHostThread {
godot.enableImmersiveMode(enabled)
}
}
private fun isInImmersiveMode() = godot.isInImmersiveMode()
private fun isInEdgeToEdgeMode() = godot.isInEdgeToEdgeMode()
private fun setKeepScreenOn(enabled: Boolean) = godot.setKeepScreenOn(enabled)
private fun restart() { godot.primaryHost?.onGodotRestartRequested(godot) }
private fun alert(message: String, title: String) {
godot.alert(message, title)
}
private fun forceQuit(instanceId: Int) = godot.forceQuit(instanceId)
/**
* Returns true if dark mode is supported, false otherwise.
*/
private fun isDarkModeSupported(): Boolean {
return godot.context.resources?.configuration?.uiMode?.and(Configuration.UI_MODE_NIGHT_MASK) != Configuration.UI_MODE_NIGHT_UNDEFINED
}
/**
* Returns true if dark mode is supported and enabled, false otherwise.
*/
private fun isDarkMode() = godot.darkMode
private fun showFilePicker(currentDirectory: String, filename: String, fileMode: Int, filters: Array<String>) {
FilePicker.showFilePicker(godot.context, godot.getActivity(), currentDirectory, filename, fileMode, filters)
}
/**
* This method shows a dialog with multiple buttons.
*
* @param title The title of the dialog.
* @param message The message displayed in the dialog.
* @param buttons An array of button labels to display.
*/
private fun showDialog(title: String, message: String, buttons: Array<String>) {
godot.getActivity()?.let { DialogUtils.showDialog(it, title, message, buttons) }
}
/**
* This method shows a dialog with a text input field, allowing the user to input text.
*
* @param title The title of the input dialog.
* @param message The message displayed in the input dialog.
* @param existingText The existing text that will be pre-filled in the input field.
*/
private fun showInputDialog(title: String, message: String, existingText: String) {
godot.getActivity()?.let { DialogUtils.showInputDialog(it, title, message, existingText) }
}
private fun getAccentColor(): Int {
val value = TypedValue()
godot.context.theme.resolveAttribute(android.R.attr.colorAccent, value, true)
return value.data
}
private fun getBaseColor(): Int {
val value = TypedValue()
godot.context.theme.resolveAttribute(android.R.attr.colorBackground, value, true)
return value.data
}
private fun requestPermission(name: String?) = godot.requestPermission(name)
private fun requestPermissions() = godot.requestPermissions()
private fun getGrantedPermissions() = godot.getGrantedPermissions()
/**
* Used by the native code (java_godot_wrapper.h) to vibrate the device.
* @param durationMs
*/
@SuppressLint("MissingPermission")
private fun vibrate(durationMs: Int, amplitude: Int) {
if (durationMs > 0 && godot.requestPermission("VIBRATE")) {
try {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
if (amplitude <= -1) {
vibratorService?.vibrate(
VibrationEffect.createOneShot(
durationMs.toLong(),
VibrationEffect.DEFAULT_AMPLITUDE
)
)
} else {
vibratorService?.vibrate(
VibrationEffect.createOneShot(
durationMs.toLong(),
amplitude
)
)
}
} else {
// deprecated in API 26
vibratorService?.vibrate(durationMs.toLong())
}
} catch (e: SecurityException) {
Log.w(
TAG,
"SecurityException: VIBRATE permission not found. Make sure it is declared in the manifest or enabled in the export preset."
)
}
}
}
/**
* Internal method used to query whether the host or the registered plugins supports a given feature.
*
* This is invoked by the native code, and should not be confused with [hasFeature] which is the Android version of
* https://docs.godotengine.org/en/stable/classes/class_os.html#class-os-method-has-feature
*/
private fun checkInternalFeatureSupport(feature: String): Boolean {
if (godot.primaryHost?.supportsFeature(feature) == true) {
return true
}
for (plugin in godot.pluginRegistry.allPlugins) {
if (plugin.supportsFeature(feature)) {
return true
}
}
return false
}
/**
* Get the list of gdextension modules to register.
*/
private fun getGDExtensionConfigFiles(): Array<String> {
val configFiles = mutableSetOf<String>()
for (plugin in godot.pluginRegistry.allPlugins) {
configFiles.addAll(plugin.pluginGDExtensionLibrariesPaths)
}
return configFiles.toTypedArray()
}
private fun getCACertificates(): String {
return GodotNetUtils.getCACertificates()
}
private fun getActivity() = godot.getActivity()
private fun getRenderView() = godot.renderView
private fun getClipboard() = godot.getClipboard()
private fun setClipboard(text: String) = godot.setClipboard(text)
private fun hasClipboard() = godot.hasClipboard()
private fun setWindowColor(color: String) = godot.setWindowColor(color)
/**
* Used by the native code (java_godot_wrapper.h) to access the input fallback mapping.
* @return The input fallback mapping for the current XR mode.
*/
private fun getInputFallbackMapping(): String? {
return godot.xrMode.inputFallbackMapping
}
private fun initInputDevices() {
godot.godotInputHandler.initInputDevices()
}
private fun createNewGodotInstance(args: Array<String>): Int {
return godot.primaryHost?.onNewGodotInstanceRequested(args) ?: -1
}
private fun nativeBeginBenchmarkMeasure(scope: String, label: String) {
beginBenchmarkMeasure(scope, label)
}
private fun nativeEndBenchmarkMeasure(scope: String, label: String) {
endBenchmarkMeasure(scope, label)
}
private fun nativeDumpBenchmark(benchmarkFile: String) {
dumpBenchmark(godot.fileAccessHandler, benchmarkFile)
}
private fun nativeSignApk(
inputPath: String,
outputPath: String,
keystorePath: String,
keystoreUser: String,
keystorePassword: String
): Int {
val signResult = godot.primaryHost?.signApk(inputPath, outputPath, keystorePath, keystoreUser, keystorePassword)
?: org.godotengine.godot.error.Error.ERR_UNAVAILABLE
return signResult.toNativeValue()
}
private fun nativeVerifyApk(apkPath: String): Int {
val verifyResult = godot.primaryHost?.verifyApk(apkPath) ?: Error.ERR_UNAVAILABLE
return verifyResult.toNativeValue()
}
private fun nativeOnEditorWorkspaceSelected(workspace: String) {
godot.primaryHost?.onEditorWorkspaceSelected(workspace)
}
private fun nativeOnDistractionFreeModeChanged(enabled: Boolean) {
godot.primaryHost?.onDistractionFreeModeChanged(enabled)
}
private fun nativeBuildEnvConnect(callback: GodotCallable): Boolean {
try {
val buildProvider = godot.primaryHost?.buildProvider
val success = buildProvider?.buildEnvConnect(callback) ?: false
if (!success) {
val activity = godot.getActivity() ?: return false
godot.runOnHostThread {
val builder = AlertDialog.Builder(activity)
.setMessage(activity.getString(R.string.gabe_connection_error_message))
.setTitle(activity.getString(R.string.gabe_connection_error_title))
.setCancelable(false)
.setPositiveButton(activity.getString(R.string.dialog_download)) { dialog: DialogInterface, _: Int ->
val intent = Intent(Intent.ACTION_VIEW, "https://godotengine.org/download/android#gabe".toUri())
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
activity.startActivity(intent)
dialog.cancel()
}
.setNegativeButton(activity.getString(R.string.dialog_cancel)) { dialog: DialogInterface, _: Int ->
dialog.dismiss()
}
val dialog = builder.create()
dialog.show()
}
}
return success
} catch (e: Exception) {
Log.e(TAG, "Unable to connect to build environment", e)
return false
}
}
private fun nativeBuildEnvDisconnect() {
try {
val buildProvider = godot.primaryHost?.buildProvider
buildProvider?.buildEnvDisconnect()
} catch (e: Exception) {
Log.e(TAG, "Unable to disconnect from build environment", e)
}
}
private fun nativeBuildEnvExecute(
buildTool: String,
arguments: Array<String>,
projectPath: String,
buildDir: String,
outputCallback: GodotCallable,
resultCallback: GodotCallable
): Int {
try {
val buildProvider = godot.primaryHost?.buildProvider
return buildProvider?.buildEnvExecute(
buildTool,
arguments,
projectPath,
buildDir,
outputCallback,
resultCallback
) ?: -1
} catch (e: Exception) {
Log.e(TAG, "Unable to execute Gradle command in build environment", e);
return -1
}
}
private fun nativeBuildEnvCancel(jobId: Int) {
try {
val buildProvider = godot.primaryHost?.buildProvider
buildProvider?.buildEnvCancel(jobId)
} catch (e: Exception) {
Log.e(TAG, "Unable to cancel command in build environment", e)
}
}
private fun nativeBuildEnvCleanProject(projectPath: String, buildDir: String, callback: GodotCallable) {
try {
val buildProvider = godot.primaryHost?.buildProvider
buildProvider?.buildEnvCleanProject(projectPath, buildDir, callback)
} catch (e: Exception) {
Log.e(TAG, "Unable to clean project in build environment", e)
}
}
private fun nativeIsPiPModeSupported(): Boolean {
val hostActivity = godot.getActivity()
if (hostActivity is PictureInPictureProvider) {
return hostActivity.isPiPModeSupported()
}
return false
}
private fun nativeIsInPiPMode(): Boolean {
val hostActivity = godot.getActivity()
if (hostActivity is GodotActivity) {
return hostActivity.isInPictureInPictureMode
}
return false
}
private fun nativeEnterPiPMode() {
val hostActivity = godot.getActivity()
if (hostActivity is PictureInPictureProvider) {
godot.runOnHostThread {
hostActivity.enterPiPMode()
}
}
}
private fun nativeSetPiPModeAspectRatio(numerator: Int, denominator: Int) {
val hostActivity = godot.getActivity()
if (hostActivity is GodotActivity) {
godot.runOnHostThread {
hostActivity.updatePiPParams(aspectRatio = Rational(numerator, denominator))
}
}
}
private fun nativeSetAutoEnterPiPModeOnBackground(autoEnterPiPOnBackground: Boolean) {
val hostActivity = godot.getActivity()
if (hostActivity is GodotActivity) {
godot.runOnHostThread {
hostActivity.updatePiPParams(enableAutoEnter = autoEnterPiPOnBackground)
}
}
}
}
@@ -32,10 +32,13 @@ package org.godotengine.godot.plugin
import android.content.Intent
import android.util.Log
import androidx.annotation.Keep
import androidx.core.net.toUri
import org.godotengine.godot.Godot
import org.godotengine.godot.variant.Callable
import java.lang.reflect.InvocationHandler
import java.lang.reflect.Proxy
/**
* Built-in Godot Android plugin used to provide access to the Android runtime capabilities.
@@ -43,7 +46,76 @@ import org.godotengine.godot.variant.Callable
* @see <a href="https://docs.godotengine.org/en/latest/tutorials/platform/android/javaclasswrapper_and_androidruntimeplugin.html">Integrating with Android APIs</a>
*/
class AndroidRuntimePlugin(godot: Godot) : GodotPlugin(godot) {
private val TAG = AndroidRuntimePlugin::class.java.simpleName
companion object {
private val TAG = AndroidRuntimePlugin::class.java.simpleName
/**
* Helper method used to generate Godot Proxy instances.
*/
@JvmStatic
@Keep
private fun generateProxyInstance(interfaces: Array<String>, invocationHandler: InvocationHandler): Any? {
try {
val interfaceClasses = interfaces.map { Class.forName(it) }.toTypedArray()
val proxy = Proxy.newProxyInstance(invocationHandler.javaClass.classLoader, interfaceClasses, invocationHandler)
return proxy
} catch (e: Exception) {
Log.w(TAG, "Error generating Godot proxy for interfaces ${interfaces.joinToString(",")}", e)
}
return null
}
/**
* Utility method used to create [java.lang.reflect.Proxy] instance wrapping a given Godot [Callable].
*
* The [Proxy] instance is used to implement one SAM interface with the [Callable] serving as the delegate
* implementation for the SAM interface overridden methods.
*/
@JvmStatic
@Keep
private fun createProxyFromGodotCallable(interfaceName: String, godotCallable: Callable): Any? {
return generateProxyInstance(arrayOf(interfaceName)) { proxy, method, args ->
when (method.name) {
// We automatically handle 'toString', 'equals' and 'hashCode' to simplify the task of the caller
// and provide consistency.
"toString" -> "Godot Callable Proxy for $interfaceName"
"equals" -> proxy === args[0]
"hashCode" -> godotCallable.hashCode()
// Invocation for the interface single abstract method falls here and is dispatched to the
// Godot [Callable].
else -> godotCallable.call(*(args ?: emptyArray()))
}
}
}
/**
* Utility method used to create [java.lang.reflect.Proxy] instance wrapping a given Godot Object represented by
* its ObjectID.
*
* The [Proxy] instance is used to implement one or multiple interfaces with the Object represented by
* [godotObjectID] serving as the delegate implementation for the interface(s) overridden methods.
*/
@JvmStatic
@Keep
private fun createProxyFromGodotObjectID(godotObjectID: Long, interfaces: Array<String>): Any? {
return generateProxyInstance(interfaces) { proxy, method, args ->
when (val methodName = method.name) {
// We automatically handle 'toString', 'equals' and 'hashCode' to simplify the task of the caller
// and provide consistency.
"toString" -> "Godot Object Proxy for ${interfaces.joinToString(",")}"
"equals" -> proxy === args[0]
"hashCode" -> godotObjectID.hashCode()
// Invocation for the remaining interface(s) methods falls here and is dispatched to the
// Godot Object.
else -> Callable.call(godotObjectID, methodName, *(args ?: emptyArray()))
}
}
}
}
override fun getPluginName() = "AndroidRuntime"
@@ -206,6 +206,16 @@ public abstract class GodotPlugin {
*/
public void onMainPause() {}
/**
* @see Activity#onStop()
*/
public void onMainStop() {}
/**
* @see Activity#onStart()
*/
public void onMainStart() {}
/**
* @see Activity#onResume()
*/
@@ -235,6 +245,11 @@ public abstract class GodotPlugin {
*/
public void onGodotMainLoopStarted() {}
/**
* Invoked on the render thread when the Godot engine is terminating.
*/
public void onGodotTerminating() {}
/**
* When using the OpenGL renderer, this is invoked once per frame on the GL thread after the
* frame is drawn.
@@ -271,6 +286,26 @@ public abstract class GodotPlugin {
*/
public void onVkSurfaceCreated(Surface surface) {}
/**
* Return the list of command line parameters that should be passed to Godot.
* <p>
* When invoking this method, the engine passes the original set of command line params it was started with,
* which can be used by the plugin to enable / disable its own feature(s).
* The given set of command line params is unmodifiable and does not include command line parameters passed by other plugins.
*
* @param unmodifiableOriginalCommandLineParams Original set of command line params the engine was started with.
* @return a new set of command line parameters to be passed to the engine.
*/
@NonNull
public List<String> getCommandLineParams(List<String> unmodifiableOriginalCommandLineParams) {
return Collections.emptyList();
}
@Override
public String toString() {
return getPluginName();
}
/**
* Returns the name of the plugin.
* <p>
@@ -414,7 +449,7 @@ public abstract class GodotPlugin {
Object signalArg = signalArgs[i];
if (signalArg != null && !signalParamTypes[i].isInstance(signalArg)) {
throw new IllegalArgumentException(
"Invalid type for argument #" + i + ". Should be of type " + signalParamTypes[i].getName());
"Invalid type for argument #" + i + ". Should be of type '" + signalParamTypes[i].getName() + "' but is of type '" + signalArg.getClass().getName() + "'.");
}
}
@@ -57,7 +57,7 @@ import java.util.Set;
*/
@Keep
public class GodotTTS extends UtteranceProgressListener implements TextToSpeech.OnInitListener {
// Note: These constants must be in sync with DisplayServer::TTSUtteranceEvent enum from "servers/display/display_server.h".
// Note: These constants must be in sync with DisplayServerEnums::TTSUtteranceEvent enum from "servers/display/display_server.h".
final private static int EVENT_START = 0;
final private static int EVENT_END = 1;
final private static int EVENT_CANCEL = 2;
@@ -64,7 +64,7 @@ private val benchmarkTracker = Collections.synchronizedMap(LinkedHashMap<Pair<St
* Note: Only enabled on 'editorDev' build variant.
*/
fun beginBenchmarkMeasure(scope: String, label: String) {
if (BuildConfig.FLAVOR != "editor" || BuildConfig.BUILD_TYPE != "dev") {
if (BuildConfig.FLAVOR != "editor" || BuildConfig.BUILD_TYPE != "debug") {
return
}
val key = Pair(scope, label)
@@ -84,7 +84,7 @@ fun beginBenchmarkMeasure(scope: String, label: String) {
*/
@JvmOverloads
fun endBenchmarkMeasure(scope: String, label: String, dumpBenchmark: Boolean = false) {
if (BuildConfig.FLAVOR != "editor" || BuildConfig.BUILD_TYPE != "dev") {
if (BuildConfig.FLAVOR != "editor" || BuildConfig.BUILD_TYPE != "debug") {
return
}
val key = Pair(scope, label)
@@ -109,7 +109,7 @@ fun endBenchmarkMeasure(scope: String, label: String, dumpBenchmark: Boolean = f
*/
@JvmOverloads
fun dumpBenchmark(fileAccessHandler: FileAccessHandler? = null, filepath: String? = benchmarkFile) {
if (BuildConfig.FLAVOR != "editor" || BuildConfig.BUILD_TYPE != "dev") {
if (BuildConfig.FLAVOR != "editor" || BuildConfig.BUILD_TYPE != "debug") {
return
}
if (!useBenchmark || benchmarkTracker.isEmpty()) {
Binary file not shown.

After

Width:  |  Height:  |  Size: 102 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.5 KiB

@@ -1,165 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical" >
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_weight="0"
android:orientation="vertical" >
<TextView
android:id="@+id/statusText"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="10dp"
android:layout_marginStart="5dp"
android:layout_marginTop="10dp"
android:textStyle="bold" />
<LinearLayout
android:id="@+id/downloaderDashboard"
android:layout_width="fill_parent"
android:layout_height="wrap_content"
android:orientation="vertical" >
<RelativeLayout
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1" >
<TextView
android:id="@+id/progressAsFraction"
style="@android:style/TextAppearance.Small"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_alignParentStart="true"
android:layout_marginStart="5dp" />
<TextView
android:id="@+id/progressAsPercentage"
style="@android:style/TextAppearance.Small"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_alignEnd="@+id/progressBar" />
<ProgressBar
android:id="@+id/progressBar"
style="?android:attr/progressBarStyleHorizontal"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_below="@+id/progressAsFraction"
android:layout_marginBottom="10dp"
android:layout_marginLeft="10dp"
android:layout_marginRight="10dp"
android:layout_marginTop="10dp" />
<TextView
android:id="@+id/progressAverageSpeed"
style="@android:style/TextAppearance.Small"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_alignParentStart="true"
android:layout_below="@+id/progressBar"
android:layout_marginStart="5dp" />
<TextView
android:id="@+id/progressTimeRemaining"
style="@android:style/TextAppearance.Small"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_alignEnd="@+id/progressBar"
android:layout_below="@+id/progressBar" />
</RelativeLayout>
<LinearLayout
android:id="@+id/downloadButton"
android:layout_width="fill_parent"
android:layout_height="wrap_content"
android:orientation="horizontal" >
<Button
android:id="@+id/cancelButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center_vertical"
android:layout_marginBottom="10dp"
android:layout_marginLeft="5dp"
android:layout_marginRight="5dp"
android:layout_marginTop="10dp"
android:layout_weight="0"
android:minHeight="40dp"
android:minWidth="94dp"
android:text="@string/text_button_cancel"
android:visibility="gone"
style="?android:attr/buttonBarButtonStyle" />
<Button
android:id="@+id/pauseButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center_vertical"
android:layout_marginBottom="10dp"
android:layout_marginStart="10dp"
android:layout_marginEnd="5dp"
android:layout_marginTop="10dp"
android:layout_weight="0"
android:minHeight="40dp"
android:minWidth="94dp"
android:text="@string/text_button_pause"
style="?android:attr/buttonBarButtonStyle" />
</LinearLayout>
</LinearLayout>
</LinearLayout>
<LinearLayout
android:id="@+id/approveCellular"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_weight="1"
android:orientation="vertical"
android:visibility="gone" >
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_margin="10dp"
android:id="@+id/textPausedParagraph1"
android:text="@string/text_paused_cellular" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_margin="10dp"
android:id="@+id/textPausedParagraph2"
android:text="@string/text_paused_cellular_2" />
<LinearLayout
android:id="@+id/buttonRow"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal" >
<Button
android:id="@+id/resumeOverCellular"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:layout_margin="10dp"
android:text="@string/text_button_resume_cellular"
style="?android:attr/buttonBarButtonStyle" />
<Button
android:id="@+id/wifiSettingsButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:layout_margin="10dp"
android:text="@string/text_button_wifi_settings"
style="?android:attr/buttonBarButtonStyle" />
</LinearLayout>
</LinearLayout>
</LinearLayout>
@@ -1,108 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
/*
** Copyright 2008, The Android Open Source Project
**
** Licensed under the Apache License, Version 2.0 (the "License");
** you may not use this file except in compliance with the License.
** You may obtain a copy of the License at
**
** http://www.apache.org/licenses/LICENSE-2.0
**
** Unless required by applicable law or agreed to in writing, software
** distributed under the License is distributed on an "AS IS" BASIS,
** WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
** See the License for the specific language governing permissions and
** limitations under the License.
*/
-->
<LinearLayout xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:baselineAligned="false"
android:orientation="horizontal" android:id="@+id/notificationLayout" xmlns:android="http://schemas.android.com/apk/res/android">
<RelativeLayout
android:layout_width="35dp"
android:layout_height="fill_parent"
android:paddingTop="10dp"
android:paddingBottom="8dp" >
<ImageView
android:id="@+id/appIcon"
android:layout_width="fill_parent"
android:layout_height="25dp"
android:scaleType="centerInside"
android:layout_alignParentStart="true"
android:layout_alignParentTop="true"
android:src="@android:drawable/stat_sys_download"
android:contentDescription="@string/godot_project_name_string" />
<TextView
android:id="@+id/progress_text"
style="@style/NotificationText"
android:layout_width="fill_parent"
android:layout_height="wrap_content"
android:layout_alignParentStart="true"
android:layout_alignParentBottom="true"
android:layout_gravity="center_horizontal"
android:singleLine="true"
android:gravity="center" />
</RelativeLayout>
<RelativeLayout
android:layout_width="0dip"
android:layout_height="match_parent"
android:layout_weight="1.0"
android:clickable="true"
android:focusable="true"
android:paddingTop="10dp"
android:paddingEnd="8dp"
android:paddingBottom="8dp"
tools:ignore="RtlSymmetry">
<TextView
android:id="@+id/title"
style="@style/NotificationTitle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_alignParentStart="true"
android:singleLine="true"/>
<TextView
android:id="@+id/time_remaining"
style="@style/NotificationText"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_alignParentEnd="true"
android:singleLine="true"
tools:ignore="RelativeOverlap" />
<!-- Only one of progress_bar and paused_text will be visible. -->
<FrameLayout
android:id="@+id/progress_bar_frame"
android:layout_width="fill_parent"
android:layout_height="wrap_content"
android:layout_alignParentBottom="true" >
<ProgressBar
android:id="@+id/progress_bar"
style="?android:attr/progressBarStyleHorizontal"
android:layout_width="fill_parent"
android:layout_height="wrap_content"
android:paddingEnd="25dp" />
<TextView
android:id="@+id/description"
style="@style/NotificationTextShadow"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:paddingEnd="25dp"
android:singleLine="true" />
</FrameLayout>
</RelativeLayout>
</LinearLayout>
@@ -1,14 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="text_paused_cellular">آیا می خواهید بر روی اتصال داده همراه دانلود را شروع کنید؟ بر اساس نوع سطح داده شما این ممکن است برای شما هزینه مالی داشته باشد.</string>
<string name="text_paused_cellular_2">اگر نمی خواهید بر روی اتصال داده همراه دانلود را شروع کنید ، دانلود به صورت خودکار در زمان دسترسی به وای-فای شروع می شود.</string>
<string name="text_button_resume_cellular">ادامه دانلود</string>
<string name="text_button_wifi_settings">تنظیمات وای-فای</string>
<string name="text_verifying_download">درحال تایید دانلود</string>
<string name="text_validation_complete">تایید فایل XAPK تکمیل شد. برای خروج تایید کنید.</string>
<string name="text_validation_failed">اعتبارسنجی فایل XAPK ناموق.</string>
<string name="text_button_pause">توقف دانلود</string>
<string name="text_button_resume">ادامه دانلود</string>
<string name="text_button_cancel">انصراف</string>
<string name="text_button_cancel_verify">انصراف از تایید شدن</string>
</resources>
@@ -1,54 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="text_paused_cellular">모바일 네트워크를 사용하여 다운로드 하시겠습니까? 남은 데이터 사용량에 따라, 요금이 부과될 수 있습니다.</string>
<string name="text_paused_cellular_2">모바일 네트워크를 사용하여 다운로드 하지 않을 경우, 와이파이 연결이 가능할 때 자동적으로 다운로드가 이루어집니다.</string>
<string name="text_button_resume_cellular">다운로드 계속하기</string>
<string name="text_button_wifi_settings">와이파이 설정</string>
<string name="text_verifying_download">다운로드 확인중</string>
<string name="text_validation_complete">추가 파일 확인이 완료되었습니다. 확인을 눌러 진행하세요.</string>
<string name="text_validation_failed">추가 파일 확인에 실패하였습니다.</string>
<string name="text_button_pause">다운로드 일시정지</string>
<string name="text_button_resume">다운로드 계속하기</string>
<string name="text_button_cancel">취소</string>
<string name="text_button_cancel_verify">파일 확인 취소</string>
<!-- APK Expansion Strings -->
<!-- When a download completes, a notification is displayed, and this
string is used to indicate that the download successfully completed.
Note that such a download could have been initiated by a variety of
applications, including (but not limited to) the browser, an email
application, a content marketplace. -->
<string name="notification_download_complete">다운로드 완료</string>
<!-- When a download completes, a notification is displayed, and this
string is used to indicate that the download failed.
Note that such a download could have been initiated by a variety of
applications, including (but not limited to) the browser, an email
application, a content marketplace. -->
<string name="notification_download_failed">다운로드 실패</string>
<string name="state_unknown">시작중…</string>
<string name="state_idle">다운로드 시작을 기다리는 중</string>
<string name="state_fetching_url">다운로드할 항목을 찾는 중</string>
<string name="state_connecting">다운로드 서버에 연결 중</string>
<string name="state_downloading">다운로드 중</string>
<string name="state_completed">다운로드 종료</string>
<string name="state_paused_network_unavailable">와이파이를 찾을 수 없어 다운로드가 일시정지 되었습니다.</string>
<string name="state_paused_network_setup_failure">다운로드가 일시정지 되었습니다. 네트워크 연결 상태를 확인하세요.</string>
<string name="state_paused_by_request">다운로드 일시정지</string>
<string name="state_paused_wifi_unavailable">와이파이가 사용하능하지 않아 다운로드가 일시정지 되었습니다.</string>
<string name="state_paused_wifi_disabled">와이파이가 비활성화 되어 다운로드가 일시정지 되었습니다.</string>
<string name="state_paused_roaming">로밍 상태이어서 다운로드가 일시정지 되었습니다.</string>
<string name="state_paused_sdcard_unavailable">외부 저장소를 사용할 수 없어 다운로드가 일시정지 되었습니다.</string>
<string name="state_failed_unlicensed">이 앱을 구매하지 않아 다운로드가 정지 되었습니다.</string>
<string name="state_failed_fetching_url">다운로드 항목을 찾을 수 없어 다운로드가 정지 되었습니다.</string>
<string name="state_failed_sdcard_full">외부 저장소가 가득차서 다운로드가 실패하였습니다.</string>
<string name="state_failed_cancelled">다운로드 취소</string>
<string name="state_failed">다운로드 실패</string>
<string name="kilobytes_per_second">%1$s KB/s</string>
<string name="time_remaining">남은 시간: %1$s</string>
<string name="time_remaining_notification">%1$s 남음</string>
</resources>
@@ -1,61 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="godot_project_name_string">godot-project-name</string>
<string name="text_paused_cellular">Would you like to enable downloading over cellular connections? Depending on your data plan, this may cost you money.</string>
<string name="text_paused_cellular_2">If you choose not to enable downloading over cellular connections, the download will automatically resume when wi-fi is available.</string>
<string name="text_button_resume_cellular">Resume download</string>
<string name="text_button_wifi_settings">Wi-Fi settings</string>
<string name="text_verifying_download">Verifying Download</string>
<string name="text_validation_complete">XAPK File Validation Complete. Select OK to exit.</string>
<string name="text_validation_failed">XAPK File Validation Failed.</string>
<string name="text_button_pause">Pause Download</string>
<string name="text_button_resume">Resume Download</string>
<string name="text_button_cancel">Cancel</string>
<string name="text_button_cancel_verify">Cancel Verification</string>
<string name="text_error_title">Error!</string>
<string name="error_engine_setup_message">Unable to setup the Godot Engine! Aborting…</string>
<string name="error_engine_setup_message">Unable to set up the Godot Engine! Aborting…</string>
<string name="error_missing_vulkan_requirements_message">Warning - this device does not meet the requirements for Vulkan support</string>
<!-- APK Expansion Strings -->
<!-- When a download completes, a notification is displayed, and this
string is used to indicate that the download successfully completed.
Note that such a download could have been initiated by a variety of
applications, including (but not limited to) the browser, an email
application, a content marketplace. -->
<string name="notification_download_complete">Download complete</string>
<!-- When a download completes, a notification is displayed, and this
string is used to indicate that the download failed.
Note that such a download could have been initiated by a variety of
applications, including (but not limited to) the browser, an email
application, a content marketplace. -->
<string name="notification_download_failed">Download unsuccessful</string>
<string name="state_unknown">Starting…</string>
<string name="state_idle">Waiting for download to start</string>
<string name="state_fetching_url">Looking for resources to download</string>
<string name="state_connecting">Connecting to the download server</string>
<string name="state_downloading">Downloading resources</string>
<string name="state_completed">Download finished</string>
<string name="state_paused_network_unavailable">Download paused because no network is available</string>
<string name="state_paused_network_setup_failure">Download paused. Test a website in browser</string>
<string name="state_paused_by_request">Download paused</string>
<string name="state_paused_wifi_unavailable">Download paused because wifi is unavailable</string>
<string name="state_paused_wifi_disabled">Download paused because wifi is disabled</string>
<string name="state_paused_roaming">Download paused because you are roaming</string>
<string name="state_paused_sdcard_unavailable">Download paused because the external storage is unavailable</string>
<string name="state_failed_unlicensed">Download failed because you may not have purchased this app</string>
<string name="state_failed_fetching_url">Download failed because the resources could not be found</string>
<string name="state_failed_sdcard_full">Download failed because the external storage is full</string>
<string name="state_failed_cancelled">Download canceled</string>
<string name="state_failed">Download failed</string>
<string name="kilobytes_per_second">%1$s KB/s</string>
<string name="time_remaining">Time remaining: %1$s</string>
<string name="time_remaining_notification">%1$s left</string>
<!-- Labels for the dialog action buttons -->
<string name="dialog_ok">OK</string>
<string name="dialog_cancel">Cancel</string>
<string name="dialog_download">Download</string>
<string name="gabe_connection_error_message">Unable to connect to the Godot Android Build Environment (GABE). Please make sure it\'s installed on your device.</string>
<string name="gabe_connection_error_title">Connection Failed!</string>
</resources>
@@ -1,25 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="NotificationText">
<item name="android:textColor">?android:attr/textColorPrimary</item>
</style>
<style name="NotificationTextShadow" parent="NotificationText">
<item name="android:textColor">?android:attr/textColorPrimary</item>
<item name="android:shadowColor">@android:color/background_dark</item>
<item name="android:shadowDx">1.0</item>
<item name="android:shadowDy">1.0</item>
<item name="android:shadowRadius">1</item>
</style>
<style name="NotificationTitle">
<item name="android:textColor">?android:attr/textColorPrimary</item>
<item name="android:textStyle">bold</item>
</style>
<style name="ButtonBackground">
<item name="android:background">@android:color/background_dark</item>
</style>
</resources>
@@ -0,0 +1,131 @@
/**************************************************************************/
/* AndroidRuntimePluginTest.kt */
/**************************************************************************/
/* This file is part of: */
/* GODOT ENGINE */
/* https://godotengine.org */
/**************************************************************************/
/* Copyright (c) 2014-present Godot Engine contributors (see AUTHORS.md). */
/* Copyright (c) 2007-2014 Juan Linietsky, Ariel Manzur. */
/* */
/* Permission is hereby granted, free of charge, to any person obtaining */
/* a copy of this software and associated documentation files (the */
/* "Software"), to deal in the Software without restriction, including */
/* without limitation the rights to use, copy, modify, merge, publish, */
/* distribute, sublicense, and/or sell copies of the Software, and to */
/* permit persons to whom the Software is furnished to do so, subject to */
/* the following conditions: */
/* */
/* The above copyright notice and this permission notice shall be */
/* included in all copies or substantial portions of the Software. */
/* */
/* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, */
/* EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF */
/* MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. */
/* IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY */
/* CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, */
/* TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE */
/* SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */
/**************************************************************************/
package org.godotengine.godot.plugin
import org.godotengine.godot.variant.Callable
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertTrue
import org.junit.Test
class AndroidRuntimePluginTest {
private val createProxyFromGodotCallable = AndroidRuntimePlugin::class.java
.getDeclaredMethod("createProxyFromGodotCallable", String::class.java, Callable::class.java)
.apply { isAccessible = true }
private val createProxyFromGodotObjectID = AndroidRuntimePlugin::class.java
.getDeclaredMethod("createProxyFromGodotObjectID", Long::class.javaPrimitiveType, Array<String>::class.java)
.apply { isAccessible = true }
private fun newCallable(): Callable {
val ctor = Callable::class.java.getDeclaredConstructor(Long::class.javaPrimitiveType)
ctor.isAccessible = true
return ctor.newInstance(0L)
}
private fun callableProxy(): Any {
val proxy = createProxyFromGodotCallable.invoke(null, Runnable::class.java.name, newCallable())
assertNotNull(proxy)
return proxy!!
}
private fun objectIDProxy(godotObjectID: Long = 0L): Any {
val proxy = createProxyFromGodotObjectID.invoke(null, godotObjectID, arrayOf(Runnable::class.java.name))
assertNotNull(proxy)
return proxy!!
}
// Regression tests for the infinite recursion in the proxy equals handler.
// Kotlin's `proxy == args[0]` compiles to `proxy.equals(args[0])`, which the
// JDK proxy dispatches back through the InvocationHandler — the previous
// implementation hit the same branch and recursed into StackOverflowError.
@Test
fun `Given Callable proxy, When equals is invoked with itself, Then returns true without recursion`() {
val proxy = callableProxy()
assertTrue(proxy.equals(proxy))
}
@Test
fun `Given Callable proxy, When equals is invoked with another instance, Then returns false without recursion`() {
val proxy = callableProxy()
val otherProxy = callableProxy()
assertFalse(proxy.equals(otherProxy))
assertFalse(proxy.equals(Any()))
assertFalse(proxy.equals(null))
}
@Test
fun `Given ObjectID proxy, When equals is invoked with itself, Then returns true without recursion`() {
val proxy = objectIDProxy()
assertTrue(proxy.equals(proxy))
}
@Test
fun `Given ObjectID proxy, When equals is invoked with another instance, Then returns false without recursion`() {
val proxy = objectIDProxy()
val otherProxy = objectIDProxy()
assertFalse(proxy.equals(otherProxy))
assertFalse(proxy.equals(Any()))
assertFalse(proxy.equals(null))
}
// Sanity checks that the proxy's toString / hashCode handlers still operate
// as documented and that they do not get caught up in the equals fix.
@Test
fun `Given Callable proxy, When toString is invoked, Then returns the documented label`() {
val proxy = callableProxy()
assertEquals("Godot Callable Proxy for ${Runnable::class.java.name}", proxy.toString())
}
@Test
fun `Given ObjectID proxy, When toString is invoked, Then returns the documented label`() {
val proxy = objectIDProxy()
assertEquals("Godot Object Proxy for ${Runnable::class.java.name}", proxy.toString())
}
@Test
fun `Given Callable proxy, When hashCode is invoked, Then returns the Callable hashCode`() {
val callable = newCallable()
val proxy = createProxyFromGodotCallable.invoke(null, Runnable::class.java.name, callable)!!
assertEquals(callable.hashCode(), proxy.hashCode())
}
@Test
fun `Given ObjectID proxy, When hashCode is invoked, Then returns the ObjectID hashCode`() {
val godotObjectID = 0x1_0000_002AL
val proxy = objectIDProxy(godotObjectID)
assertEquals(godotObjectID.hashCode(), proxy.hashCode())
}
}
@@ -9,6 +9,7 @@ set(CMAKE_CXX_EXTENSIONS OFF)
set(GODOT_ROOT_DIR ../../../..)
set(ANDROID_ROOT_DIR "${GODOT_ROOT_DIR}/platform/android" CACHE STRING "")
set(OPENXR_INCLUDE_DIR "${GODOT_ROOT_DIR}/thirdparty/openxr/include" CACHE STRING "")
set(PERFETTO_INCLUDE_DIR "${GODOT_ROOT_DIR}/thirdparty/perfetto" CACHE STRING "")
# Get sources
file(GLOB_RECURSE SOURCES ${GODOT_ROOT_DIR}/*.c**)
@@ -19,7 +20,8 @@ target_include_directories(${PROJECT_NAME}
SYSTEM PUBLIC
${GODOT_ROOT_DIR}
${ANDROID_ROOT_DIR}
${OPENXR_INCLUDE_DIR})
${OPENXR_INCLUDE_DIR}
${PERFETTO_INCLUDE_DIR})
add_definitions(
-DUNIX_ENABLED
@@ -29,4 +31,5 @@ add_definitions(
-DTOOLS_ENABLED
-DDEBUG_ENABLED
-DRD_ENABLED
-DGODOT_USE_PERFETTO
)