Ladybird: Add WebContentService for Android port
This will let us spawn a new process for an Android Service to handle all our WebContent needs. The ServiceConnection is manged by each WebView. The lifecycle of the Service is not quite clear yet, but each bindService call will get a unique Messenger that can be used to transfer the WebContent side of the LibIPC socketpair we use in other ports.
This commit is contained in:
parent
ffc0046d74
commit
6952de73dc
Notes:
sideshowbarker
2024-07-17 08:34:29 +09:00
Author: https://github.com/ADKaster Commit: https://github.com/SerenityOS/serenity/commit/6952de73dc Pull-request: https://github.com/SerenityOS/serenity/pull/20978 Reviewed-by: https://github.com/bugaevc
9 changed files with 334 additions and 40 deletions
Ladybird
|
@ -1,41 +1,54 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:installLocation="auto"
|
||||
android:versionCode="001"
|
||||
android:versionName="head">
|
||||
<supports-screens android:anyDensity="true"
|
||||
android:largeScreens="true"
|
||||
android:normalScreens="true"
|
||||
android:smallScreens="true"/>
|
||||
<application
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:installLocation="auto"
|
||||
android:versionCode="001"
|
||||
android:versionName="head">
|
||||
|
||||
<supports-screens
|
||||
android:anyDensity="true"
|
||||
android:largeScreens="true"
|
||||
android:normalScreens="true"
|
||||
android:smallScreens="true" />
|
||||
|
||||
<uses-permission android:name="com.android.browser.permission.READ_HISTORY_BOOKMARKS" />
|
||||
|
||||
<application
|
||||
android:allowBackup="true"
|
||||
android:allowNativeHeapPointerTagging="false"
|
||||
android:dataExtractionRules="@xml/data_extraction_rules"
|
||||
android:enableOnBackInvokedCallback="true"
|
||||
android:fullBackupContent="@xml/backup_rules"
|
||||
android:fullBackupOnly="false"
|
||||
android:hardwareAccelerated="true"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
android:requestLegacyExternalStorage="true"
|
||||
android:roundIcon="@mipmap/ic_launcher_round"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/Theme.Ladybird"
|
||||
tools:targetApi="33"
|
||||
android:hardwareAccelerated="true"
|
||||
android:label="@string/app_name"
|
||||
android:requestLegacyExternalStorage="true"
|
||||
android:allowNativeHeapPointerTagging="false"
|
||||
android:allowBackup="true"
|
||||
android:fullBackupOnly="false"
|
||||
android:enableOnBackInvokedCallback="true">
|
||||
<activity
|
||||
tools:targetApi="33">
|
||||
|
||||
<activity
|
||||
android:name=".LadybirdActivity"
|
||||
android:configChanges="orientation|uiMode|screenLayout|screenSize|smallestScreenSize|layoutDirection|locale|fontScale|keyboard|keyboardHidden|navigation|mcc|mnc|density"
|
||||
android:exported="true"
|
||||
android:label="@string/app_name"
|
||||
android:launchMode="singleTop"
|
||||
android:screenOrientation="unspecified"
|
||||
android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
<meta-data android:name="android.app.extract_android_style" android:value="minimal"/>
|
||||
</activity>
|
||||
</application>
|
||||
<uses-permission android:name="com.android.browser.permission.READ_HISTORY_BOOKMARKS"/>
|
||||
android:screenOrientation="unspecified">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
<meta-data
|
||||
android:name="android.app.extract_android_style"
|
||||
android:value="minimal" />
|
||||
</activity>
|
||||
|
||||
<service
|
||||
android:name=".WebContentService"
|
||||
android:exported="false"
|
||||
android:process=":WebContent"/>
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
|
|
17
Ladybird/Android/src/main/cpp/WebContentService.cpp
Normal file
17
Ladybird/Android/src/main/cpp/WebContentService.cpp
Normal file
|
@ -0,0 +1,17 @@
|
|||
/*
|
||||
* Copyright (c) 2023, Andrew Kaster <akaster@serenityos.org>
|
||||
*
|
||||
* SPDX-License-Identifier: BSD-2-Clause
|
||||
*/
|
||||
|
||||
#include <android/log.h>
|
||||
#include <jni.h>
|
||||
#include <unistd.h>
|
||||
|
||||
extern "C" JNIEXPORT void JNICALL
|
||||
Java_org_serenityos_ladybird_WebContentService_nativeHandleTransferSockets(JNIEnv*, jobject /* thiz */, jint ipc_socket, jint fd_passing_socket)
|
||||
{
|
||||
__android_log_print(ANDROID_LOG_INFO, "WebContent", "New binding received, sockets %d and %d", ipc_socket, fd_passing_socket);
|
||||
::close(ipc_socket);
|
||||
::close(fd_passing_socket);
|
||||
}
|
|
@ -4,9 +4,10 @@
|
|||
* SPDX-License-Identifier: BSD-2-Clause
|
||||
*/
|
||||
|
||||
#include <LibGfx/Bitmap.h>
|
||||
#include <LibGfx/Painter.h>
|
||||
#include <LibWebView/ViewImplementation.h>
|
||||
#include <Userland/Libraries/LibGfx/Bitmap.h>
|
||||
#include <Userland/Libraries/LibGfx/Painter.h>
|
||||
#include <Userland/Libraries/LibWeb/Crypto/Crypto.h>
|
||||
#include <Userland/Libraries/LibWebView/ViewImplementation.h>
|
||||
#include <android/bitmap.h>
|
||||
#include <android/log.h>
|
||||
#include <jni.h>
|
||||
|
@ -23,13 +24,75 @@ Gfx::BitmapFormat to_gfx_bitmap_format(i32 f)
|
|||
}
|
||||
}
|
||||
|
||||
class JavaEnvironment {
|
||||
public:
|
||||
JavaEnvironment(JavaVM* vm)
|
||||
: m_vm(vm)
|
||||
{
|
||||
auto ret = m_vm->GetEnv(reinterpret_cast<void**>(&m_env), JNI_VERSION_1_6);
|
||||
if (ret == JNI_EDETACHED) {
|
||||
ret = m_vm->AttachCurrentThread(&m_env, nullptr);
|
||||
VERIFY(ret == JNI_OK);
|
||||
m_did_attach_thread = true;
|
||||
} else if (ret == JNI_EVERSION) {
|
||||
VERIFY_NOT_REACHED();
|
||||
} else {
|
||||
VERIFY(ret == JNI_OK);
|
||||
}
|
||||
|
||||
VERIFY(m_env != nullptr);
|
||||
}
|
||||
|
||||
~JavaEnvironment()
|
||||
{
|
||||
if (m_did_attach_thread)
|
||||
m_vm->DetachCurrentThread();
|
||||
}
|
||||
|
||||
JNIEnv* get() const { return m_env; }
|
||||
|
||||
private:
|
||||
JavaVM* m_vm = nullptr;
|
||||
JNIEnv* m_env = nullptr;
|
||||
bool m_did_attach_thread = false;
|
||||
};
|
||||
|
||||
class WebViewImplementationNative : public WebView::ViewImplementation {
|
||||
public:
|
||||
WebViewImplementationNative(jobject thiz)
|
||||
: m_java_instance(thiz)
|
||||
{
|
||||
// NOTE: m_java_instance's global ref is controlled by the JNI bindings
|
||||
create_client(WebView::EnableCallgrindProfiling::No);
|
||||
}
|
||||
|
||||
virtual Gfx::IntRect viewport_rect() const override { return m_viewport_rect; }
|
||||
virtual Gfx::IntPoint to_content_position(Gfx::IntPoint p) const override { return p; }
|
||||
virtual Gfx::IntPoint to_widget_position(Gfx::IntPoint p) const override { return p; }
|
||||
virtual void update_zoom() override { }
|
||||
|
||||
NonnullRefPtr<WebView::WebContentClient> bind_web_content_client();
|
||||
|
||||
virtual void create_client(WebView::EnableCallgrindProfiling) override
|
||||
{
|
||||
m_client_state = {};
|
||||
|
||||
auto new_client = bind_web_content_client();
|
||||
|
||||
m_client_state.client = new_client;
|
||||
m_client_state.client->on_web_content_process_crash = [] {
|
||||
__android_log_print(ANDROID_LOG_ERROR, "Ladybird", "WebContent crashed!");
|
||||
// FIXME: launch a new client
|
||||
};
|
||||
|
||||
m_client_state.client_handle = MUST(Web::Crypto::generate_random_uuid());
|
||||
client().async_set_window_handle(m_client_state.client_handle);
|
||||
|
||||
client().async_set_device_pixels_per_css_pixel(m_device_pixel_ratio);
|
||||
|
||||
// FIXME: update_palette, update system fonts
|
||||
}
|
||||
|
||||
void paint_into_bitmap(void* android_bitmap_raw, AndroidBitmapInfo const& info)
|
||||
{
|
||||
// Software bitmaps only for now!
|
||||
|
@ -64,40 +127,89 @@ public:
|
|||
|
||||
static jclass global_class_reference;
|
||||
static jfieldID instance_pointer_field;
|
||||
static jmethodID bind_webcontent_method;
|
||||
static JavaVM* global_vm;
|
||||
|
||||
jobject java_instance() const { return m_java_instance; }
|
||||
|
||||
private:
|
||||
jobject m_java_instance = nullptr;
|
||||
Gfx::IntRect m_viewport_rect;
|
||||
};
|
||||
jclass WebViewImplementationNative::global_class_reference;
|
||||
jfieldID WebViewImplementationNative::instance_pointer_field;
|
||||
jmethodID WebViewImplementationNative::bind_webcontent_method;
|
||||
JavaVM* WebViewImplementationNative::global_vm;
|
||||
|
||||
NonnullRefPtr<WebView::WebContentClient> WebViewImplementationNative::bind_web_content_client()
|
||||
{
|
||||
JavaEnvironment env(WebViewImplementationNative::global_vm);
|
||||
|
||||
int socket_fds[2] {};
|
||||
MUST(Core::System::socketpair(AF_LOCAL, SOCK_STREAM, 0, socket_fds));
|
||||
|
||||
int ui_fd = socket_fds[0];
|
||||
int wc_fd = socket_fds[1];
|
||||
|
||||
int fd_passing_socket_fds[2] {};
|
||||
MUST(Core::System::socketpair(AF_LOCAL, SOCK_STREAM, 0, fd_passing_socket_fds));
|
||||
|
||||
int ui_fd_passing_fd = fd_passing_socket_fds[0];
|
||||
int wc_fd_passing_fd = fd_passing_socket_fds[1];
|
||||
|
||||
// NOTE: The java object takes ownership of the socket fds
|
||||
env.get()->CallVoidMethod(m_java_instance, bind_webcontent_method, wc_fd, wc_fd_passing_fd);
|
||||
|
||||
auto socket = MUST(Core::LocalSocket::adopt_fd(ui_fd));
|
||||
MUST(socket->set_blocking(true));
|
||||
|
||||
auto new_client = make_ref_counted<WebView::WebContentClient>(move(socket), *this);
|
||||
new_client->set_fd_passing_socket(MUST(Core::LocalSocket::adopt_fd(ui_fd_passing_fd)));
|
||||
|
||||
return new_client;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
extern "C" JNIEXPORT void JNICALL
|
||||
Java_org_serenityos_ladybird_WebViewImplementation_00024Companion_nativeClassInit(JNIEnv* env, jobject /* thiz */)
|
||||
{
|
||||
auto ret = env->GetJavaVM(&WebViewImplementationNative::global_vm);
|
||||
if (ret != 0)
|
||||
TODO();
|
||||
|
||||
auto local_class = env->FindClass("org/serenityos/ladybird/WebViewImplementation");
|
||||
if (!local_class)
|
||||
TODO();
|
||||
WebViewImplementationNative::global_class_reference = reinterpret_cast<jclass>(env->NewGlobalRef(local_class));
|
||||
env->DeleteLocalRef(local_class);
|
||||
|
||||
auto field = env->GetFieldID(WebViewImplementationNative::global_class_reference, "nativeInstance", "J");
|
||||
if (!field)
|
||||
TODO();
|
||||
WebViewImplementationNative::instance_pointer_field = field;
|
||||
|
||||
auto method = env->GetMethodID(WebViewImplementationNative::global_class_reference, "bindWebContentService", "(II)V");
|
||||
if (!method)
|
||||
TODO();
|
||||
WebViewImplementationNative::bind_webcontent_method = method;
|
||||
}
|
||||
|
||||
extern "C" JNIEXPORT jlong JNICALL
|
||||
Java_org_serenityos_ladybird_WebViewImplementation_nativeObjectInit(JNIEnv*, jobject /* thiz */)
|
||||
Java_org_serenityos_ladybird_WebViewImplementation_nativeObjectInit(JNIEnv* env, jobject thiz)
|
||||
{
|
||||
auto instance = reinterpret_cast<jlong>(new WebViewImplementationNative);
|
||||
auto ref = env->NewGlobalRef(thiz);
|
||||
auto instance = reinterpret_cast<jlong>(new WebViewImplementationNative(ref));
|
||||
__android_log_print(ANDROID_LOG_DEBUG, "Ladybird", "New WebViewImplementationNative at %p", reinterpret_cast<void*>(instance));
|
||||
return instance;
|
||||
}
|
||||
|
||||
extern "C" JNIEXPORT void JNICALL
|
||||
Java_org_serenityos_ladybird_WebViewImplementation_nativeObjectDispose(JNIEnv*, jobject /* thiz */, jlong instance)
|
||||
Java_org_serenityos_ladybird_WebViewImplementation_nativeObjectDispose(JNIEnv* env, jobject /* thiz */, jlong instance)
|
||||
{
|
||||
delete reinterpret_cast<WebViewImplementationNative*>(instance);
|
||||
auto* impl = reinterpret_cast<WebViewImplementationNative*>(instance);
|
||||
env->DeleteGlobalRef(impl->java_instance());
|
||||
delete impl;
|
||||
__android_log_print(ANDROID_LOG_DEBUG, "Ladybird", "Destroyed WebViewImplementationNative at %p", reinterpret_cast<void*>(instance));
|
||||
}
|
||||
|
||||
|
|
|
@ -0,0 +1,70 @@
|
|||
/**
|
||||
* Copyright (c) 2023, Andrew Kaster <akaster@serenityos.org>
|
||||
*
|
||||
* SPDX-License-Identifier: BSD-2-Clause
|
||||
*/
|
||||
|
||||
package org.serenityos.ladybird
|
||||
|
||||
import android.app.Service
|
||||
import android.content.Intent
|
||||
import android.util.Log
|
||||
import android.os.ParcelFileDescriptor
|
||||
import android.os.Handler
|
||||
import android.os.IBinder
|
||||
import android.os.Message
|
||||
import android.os.Messenger
|
||||
|
||||
const val MSG_TRANSFER_SOCKETS = 1
|
||||
|
||||
class WebContentService : Service() {
|
||||
private val TAG = "WebContentService"
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
Log.i(TAG, "Creating Service")
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
super.onDestroy()
|
||||
Log.i(TAG, "Destroying Service")
|
||||
}
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
Log.i(TAG, "Start command received")
|
||||
return super.onStartCommand(intent, flags, startId)
|
||||
}
|
||||
|
||||
private fun handleTransferSockets(msg: Message) {
|
||||
val bundle = msg.data
|
||||
// FIXME: Handle garbage messages from wierd clients
|
||||
val ipcSocket = bundle.getParcelable<ParcelFileDescriptor>("IPC_SOCKET")!!
|
||||
val fdSocket = bundle.getParcelable<ParcelFileDescriptor>("FD_PASSING_SOCKET")!!
|
||||
nativeHandleTransferSockets(ipcSocket.detachFd(), fdSocket.detachFd())
|
||||
}
|
||||
|
||||
private external fun nativeHandleTransferSockets(ipcSocket: Int, fdPassingSocket: Int)
|
||||
|
||||
internal class IncomingHandler(
|
||||
context: WebContentService,
|
||||
private val owner: WebContentService = context
|
||||
) : Handler() {
|
||||
override fun handleMessage(msg: Message) {
|
||||
when (msg.what) {
|
||||
MSG_TRANSFER_SOCKETS -> this.owner.handleTransferSockets(msg)
|
||||
else -> super.handleMessage(msg)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onBind(p0: Intent?): IBinder? {
|
||||
// FIXME: Check the intent to make sure it's legit
|
||||
return Messenger(IncomingHandler(this)).binder
|
||||
}
|
||||
|
||||
companion object {
|
||||
init {
|
||||
System.loadLibrary("webcontent")
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,46 @@
|
|||
/**
|
||||
* Copyright (c) 2023, Andrew Kaster <akaster@serenityos.org>
|
||||
*
|
||||
* SPDX-License-Identifier: BSD-2-Clause
|
||||
*/
|
||||
|
||||
package org.serenityos.ladybird
|
||||
|
||||
import android.content.ComponentName
|
||||
import android.content.ServiceConnection
|
||||
import android.os.IBinder
|
||||
import android.os.Message
|
||||
import android.os.Messenger
|
||||
import android.os.ParcelFileDescriptor
|
||||
|
||||
class WebContentServiceConnection(private var ipcFd: Int, private var fdPassingFd: Int) :
|
||||
ServiceConnection {
|
||||
var boundToWebContent: Boolean = false
|
||||
var onDisconnect: () -> Unit = {}
|
||||
private var webContentService: Messenger? = null
|
||||
|
||||
override fun onServiceConnected(className: ComponentName, svc: IBinder) {
|
||||
// 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.
|
||||
webContentService = Messenger(svc)
|
||||
boundToWebContent = true
|
||||
|
||||
var msg = Message.obtain(null, MSG_TRANSFER_SOCKETS)
|
||||
msg.data.putParcelable("IPC_SOCKET", ParcelFileDescriptor.adoptFd(ipcFd))
|
||||
msg.data.putParcelable("FD_PASSING_SOCKET", ParcelFileDescriptor.adoptFd(fdPassingFd))
|
||||
webContentService!!.send(msg)
|
||||
}
|
||||
|
||||
override fun onServiceDisconnected(className: ComponentName) {
|
||||
// This is called when the connection with the service has been
|
||||
// unexpectedly disconnected; that is, its process crashed.
|
||||
webContentService = null
|
||||
boundToWebContent = false
|
||||
|
||||
// Notify owner that the service is dead
|
||||
onDisconnect()
|
||||
}
|
||||
}
|
|
@ -1,3 +1,9 @@
|
|||
/**
|
||||
* Copyright (c) 2023, Andrew Kaster <akaster@serenityos.org>
|
||||
*
|
||||
* SPDX-License-Identifier: BSD-2-Clause
|
||||
*/
|
||||
|
||||
package org.serenityos.ladybird
|
||||
|
||||
import android.content.Context
|
||||
|
@ -8,7 +14,7 @@ import android.view.View
|
|||
|
||||
// FIXME: This should (eventually) implement NestedScrollingChild3 and ScrollingView
|
||||
class WebView(context: Context, attributeSet: AttributeSet) : View(context, attributeSet) {
|
||||
private val viewImpl = WebViewImplementation()
|
||||
private val viewImpl = WebViewImplementation(context)
|
||||
private lateinit var contentBitmap: Bitmap
|
||||
|
||||
fun dispose() {
|
||||
|
|
|
@ -6,13 +6,18 @@
|
|||
|
||||
package org.serenityos.ladybird
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.graphics.Bitmap
|
||||
import android.util.Log
|
||||
|
||||
/**
|
||||
* Wrapper around WebView::ViewImplementation for use by Kotlin
|
||||
*/
|
||||
class WebViewImplementation {
|
||||
class WebViewImplementation(
|
||||
context: Context,
|
||||
private var appContext: Context = context.applicationContext
|
||||
) {
|
||||
// Instance Pointer to native object, very unsafe :)
|
||||
private var nativeInstance = nativeObjectInit()
|
||||
|
||||
|
@ -36,6 +41,20 @@ class WebViewImplementation {
|
|||
nativeSetViewportGeometry(nativeInstance, w, h)
|
||||
}
|
||||
|
||||
fun bindWebContentService(ipcFd: Int, fdPassingFd: Int) {
|
||||
var connector = WebContentServiceConnection(ipcFd, fdPassingFd)
|
||||
connector.onDisconnect = {
|
||||
// FIXME: Notify impl that service is dead and might need restarted
|
||||
Log.e("WebContentView", "WebContent Died! :(")
|
||||
}
|
||||
// FIXME: Unbind this at some point maybe
|
||||
appContext.bindService(
|
||||
Intent(appContext, WebContentService::class.java),
|
||||
connector,
|
||||
Context.BIND_AUTO_CREATE
|
||||
)
|
||||
}
|
||||
|
||||
private external fun nativeObjectInit(): Long
|
||||
private external fun nativeObjectDispose(instance: Long)
|
||||
|
||||
|
|
|
@ -29,8 +29,11 @@ if (ENABLE_QT)
|
|||
target_link_libraries(WebContent PRIVATE Qt::Core Qt::Network Qt::Multimedia)
|
||||
target_compile_definitions(WebContent PRIVATE HAVE_QT=1)
|
||||
else()
|
||||
# FIXME: Remove when chromes are upstreamed
|
||||
add_library(webcontent STATIC ${WEBCONTENT_SOURCES})
|
||||
set(LIB_TYPE STATIC)
|
||||
if (ANDROID)
|
||||
set(LIB_TYPE SHARED)
|
||||
endif()
|
||||
add_library(webcontent ${LIB_TYPE} ${WEBCONTENT_SOURCES})
|
||||
target_include_directories(webcontent PRIVATE ${SERENITY_SOURCE_DIR}/Userland/Services/)
|
||||
target_include_directories(webcontent PRIVATE ${CMAKE_CURRENT_BINARY_DIR}/..)
|
||||
target_link_libraries(webcontent PRIVATE LibAudio LibCore LibFileSystem LibGfx LibIPC LibJS LibMain LibWeb LibWebSocket LibProtocol LibWebView)
|
||||
|
@ -49,6 +52,11 @@ else()
|
|||
${WEBCONTENT_SOURCE_DIR}/WebDriverConnection.h
|
||||
)
|
||||
|
||||
if (ANDROID)
|
||||
target_sources(webcontent PRIVATE ../Android/src/main/cpp/WebContentService.cpp)
|
||||
target_link_libraries(webcontent PRIVATE log)
|
||||
endif()
|
||||
|
||||
add_executable(WebContent main.cpp)
|
||||
target_link_libraries(WebContent PRIVATE webcontent)
|
||||
endif()
|
||||
|
|
|
@ -38,7 +38,10 @@ list(REMOVE_ITEM all_required_lagom_libraries ladybird)
|
|||
|
||||
# Install webcontent impl library if it exists
|
||||
if (TARGET webcontent)
|
||||
list(APPEND all_required_lagom_libraries webcontent)
|
||||
get_target_property(target_type webcontent TYPE)
|
||||
if ("${target_type}" STREQUAL STATIC_LIBRARY)
|
||||
list(APPEND all_required_lagom_libraries webcontent)
|
||||
endif()
|
||||
endif()
|
||||
|
||||
install(TARGETS ${all_required_lagom_libraries}
|
||||
|
|
Loading…
Add table
Reference in a new issue