diff --git a/.gitignore b/.gitignore index bde69d4..75d16bd 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,5 @@ -target/ -**/*.rs.bk -Cargo.lock +/target/ +/Cargo.lock # CI /.rustup diff --git a/Cargo.toml b/Cargo.toml index 4eb6441..addf857 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -16,9 +16,12 @@ edition = "2021" default = ["rich_presence"] rich_presence = [] tokio-parking_lot = ["tokio/parking_lot"] +java-bindings = ["lazy_static", "jni", "tokio/rt-multi-thread"] [dependencies] async-trait = "0.1.52" +jni = { version = "0.19", optional = true } +lazy_static = { version = "1.4", optional = true } tokio = { version = "1.16", features = ["io-util", "net", "sync", "macros", "rt"] } serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" @@ -35,3 +38,10 @@ tokio = { version = "1.16", features = [ "macros", "parking_lot", ] } + +# this is a workaround to not being able to specify either multiple libs or conditional compilation based on crate-type. +[[example]] +name = "discord_rpc_java" +path = "examples/java.rs" +crate-type = ["cdylib"] +required-features = ["java-bindings"] diff --git a/examples/java.rs b/examples/java.rs new file mode 100644 index 0000000..d9e8b2f --- /dev/null +++ b/examples/java.rs @@ -0,0 +1,21 @@ +use discord_rpc_client::java::*; + +use jni::JNIEnv; +use jni::objects::{JClass, JString, JObject}; + +// can't just put these in src/java.rs and reexport because of some tree-shaking that the compiler does. + +#[no_mangle] +pub extern "system" fn Java_com_discord_rpc_DiscordRPC_create<'a>(env: JNIEnv<'a>, class: JClass) -> JObject<'a> { + Java_com_discord_rpc_DiscordRPC_create0(env, class).unwrap_or(JObject::null()) +} + +#[no_mangle] +pub extern "system" fn Java_com_discord_rpc_DiscordRPC_connect(env: JNIEnv, obj: JObject, client_id: JString) -> bool { + Java_com_discord_rpc_DiscordRPC_connect0(env, obj, client_id).is_ok() +} + +#[no_mangle] +pub extern "system" fn Java_com_discord_rpc_DiscordRPC_setActivity(env: JNIEnv, obj: JObject, j_activity: JObject) -> bool { + Java_com_discord_rpc_DiscordRPC_setActivity0(env, obj, j_activity).is_ok() +} diff --git a/java/.gitignore b/java/.gitignore new file mode 100644 index 0000000..f457044 --- /dev/null +++ b/java/.gitignore @@ -0,0 +1,3 @@ +/.bsp/ +/target/ +/project/ diff --git a/java/build.sbt b/java/build.sbt new file mode 100644 index 0000000..01e2596 --- /dev/null +++ b/java/build.sbt @@ -0,0 +1,8 @@ +ThisBuild / organization := "com.discord" +ThisBuild / version := "0.2.0" + +lazy val hello = (project in file(".")) + .settings( + name := "discord-rpc" + ) + diff --git a/java/src/main/java/com/discord/rpc/Activity.java b/java/src/main/java/com/discord/rpc/Activity.java new file mode 100644 index 0000000..e47d028 --- /dev/null +++ b/java/src/main/java/com/discord/rpc/Activity.java @@ -0,0 +1,61 @@ +package com.discord.rpc; + +public final class Activity { + public String state; + public String details; + public boolean instance; + public ActivityTimestamps timestamps; + public ActivityAssets assets; + public ActivityParty party; + public ActivitySecrets secrets; + + public Activity() { + this.state = null; + this.details = null; + this.instance = false; + this.timestamps = null; + this.assets = null; + this.party = null; + this.secrets = null; + } + + public Activity withState(String state) { + this.state = state; + return this; + } + + public Activity withDetails(String details) { + this.details = details; + return this; + } + + public Activity withInstance(boolean instance) { + this.instance = instance; + return this; + } + + public Activity withTimestamps(ActivityTimestamps timestamps) { + this.timestamps = timestamps; + return this; + } + + public Activity withAssets(ActivityAssets assets) { + this.assets = assets; + return this; + } + + public Activity withParty(ActivityParty party) { + this.party = party; + return this; + } + + public Activity withSecrets(ActivitySecrets secrets) { + this.secrets = secrets; + return this; + } + + public Activity copy() { + return new Activity().withState(this.state).withDetails(this.details).withInstance(this.instance).withTimestamps(this.timestamps).withAssets(this.assets).withParty(this.party).withSecrets(this.secrets); + } +} + diff --git a/java/src/main/java/com/discord/rpc/ActivityAssets.java b/java/src/main/java/com/discord/rpc/ActivityAssets.java new file mode 100644 index 0000000..02366c6 --- /dev/null +++ b/java/src/main/java/com/discord/rpc/ActivityAssets.java @@ -0,0 +1,9 @@ +package com.discord.rpc; + +public record ActivityAssets( + String largeImage, + String largeText, + String smallImage, + String smallText +) {} + diff --git a/java/src/main/java/com/discord/rpc/ActivityParty.java b/java/src/main/java/com/discord/rpc/ActivityParty.java new file mode 100644 index 0000000..70b9458 --- /dev/null +++ b/java/src/main/java/com/discord/rpc/ActivityParty.java @@ -0,0 +1,4 @@ +package com.discord.rpc; + +public record ActivityParty(@Unsigned int id, @Unsigned int minSize, @Unsigned int maxSize) {} + diff --git a/java/src/main/java/com/discord/rpc/ActivitySecrets.java b/java/src/main/java/com/discord/rpc/ActivitySecrets.java new file mode 100644 index 0000000..9638d62 --- /dev/null +++ b/java/src/main/java/com/discord/rpc/ActivitySecrets.java @@ -0,0 +1,4 @@ +package com.discord.rpc; + +public record ActivitySecrets(String join, String spectate, String game) {} + diff --git a/java/src/main/java/com/discord/rpc/ActivityTimestamps.java b/java/src/main/java/com/discord/rpc/ActivityTimestamps.java new file mode 100644 index 0000000..cfa8367 --- /dev/null +++ b/java/src/main/java/com/discord/rpc/ActivityTimestamps.java @@ -0,0 +1,4 @@ +package com.discord.rpc; + +public record ActivityTimestamps(@Unsigned Long start, @Unsigned Long end) {} + diff --git a/java/src/main/java/com/discord/rpc/DiscordRPC.java b/java/src/main/java/com/discord/rpc/DiscordRPC.java new file mode 100644 index 0000000..6d313f7 --- /dev/null +++ b/java/src/main/java/com/discord/rpc/DiscordRPC.java @@ -0,0 +1,30 @@ +package com.discord.rpc; + +import java.io.File; + +public final class DiscordRPC { + private final long handle; + + private DiscordRPC(long handle) { + this.handle = handle; + } + + /** + * @return the new client instance, or null if an error occurred. + */ + public static native DiscordRPC create(); + + public native boolean connect(String clientId); + + public native boolean setActivity(Activity activity); + + static { + final var dir = System.getProperty("com.discord.librarypath"); + if (dir != null) { + System.load(dir + File.separator + System.mapLibraryName("discord_rpc")); + } else { + System.loadLibrary("discord_rpc"); + } + } +} + diff --git a/java/src/main/java/com/discord/rpc/Unsigned.java b/java/src/main/java/com/discord/rpc/Unsigned.java new file mode 100644 index 0000000..b35b13e --- /dev/null +++ b/java/src/main/java/com/discord/rpc/Unsigned.java @@ -0,0 +1,7 @@ +package com.discord.rpc; + +/** + * Marks an integer that will be re-interpreted natively as unsigned. Use Kotlin's unsigned types with these. + */ +@interface Unsigned {} + diff --git a/src/java.rs b/src/java.rs new file mode 100644 index 0000000..9562fc9 --- /dev/null +++ b/src/java.rs @@ -0,0 +1,233 @@ +use log::{log, log_enabled, trace, debug, info, warn, error}; + +use std::sync::{Arc, MutexGuard}; + +use jni::JNIEnv; +use jni::objects::{JClass, JString, JObject, JValue}; +use jni::signature::{JavaType, Primitive}; +use jni::sys::jstring; + +pub use jni; + +use crate as drpc; + +const SIG_BOOL: &'static str = "Z"; +const SIG_INT: &'static str = "I"; +const SIG_LONG: &'static str = "J"; +const SIG_NULLABLE_LONG: &'static str = "Ljava/lang/Long;"; +const SIG_STRING: &'static str = "Ljava/lang/String;"; + +const NAME_DISCORD_RPC: &'static str = "com/discord/rpc/DiscordRPC"; + +lazy_static::lazy_static! { + static ref RUNTIME: Arc = Arc::new(tokio::runtime::Runtime::new().expect("unable to create tokio runtime")); +} + +fn debug_and_discard_err(result: Result) -> Result { + result.map_err(|e| { + error!("{:?}", e); + () + }) +} + +fn is_null<'b, O>(env: JNIEnv, ref1: O) -> jni::errors::Result where O: Into> { + env.is_same_object(ref1, JObject::null()) +} + +// taking the [`JNIEnv`] as a reference is needed to satisfy the lifetime checker. +fn get_client<'a>(env: &'a JNIEnv<'a>, obj: JObject<'a>) -> Result, ()> { + if !debug_and_discard_err(env.is_instance_of(obj, NAME_DISCORD_RPC))? { + error!("not an instance of DiscordRPC"); + return Err(()) + } + + let client = env.get_rust_field::<_, _, drpc::Client>(obj, "handle"); + debug_and_discard_err(client) +} + +#[inline(always)] +pub fn Java_com_discord_rpc_DiscordRPC_create0<'a>(env: JNIEnv<'a>, _class: JClass) -> Result, ()> { + let client = drpc::Client::default(); + + let jobj = debug_and_discard_err(env.alloc_object(NAME_DISCORD_RPC))?; + debug_and_discard_err(env.set_rust_field(jobj, "handle", client))?; + Ok(jobj) +} + +#[inline(always)] +pub fn Java_com_discord_rpc_DiscordRPC_connect0(env: JNIEnv, obj: JObject, client_id: JString) -> Result<(), ()> { + let client = get_client(&env, obj)?; + + let client_id = debug_and_discard_err( + debug_and_discard_err( + debug_and_discard_err(env.get_string(client_id))?.to_str() + )?.parse::() + )?; + + if let Some(current_client_id) = client.client_id() { + if current_client_id != client_id { + RUNTIME.block_on(async { client.disconnect().await }); + } + } + + debug_and_discard_err(RUNTIME.block_on(async { client.connect(client_id).await }))?; + Ok(()) +} + +#[inline(always)] +pub fn Java_com_discord_rpc_DiscordRPC_setActivity0(env: JNIEnv, obj: JObject, j_activity: JObject) -> Result<(), ()> { + let client = get_client(&env, obj)?; + + let activity = jobject_to_activity(env, j_activity)?; + debug_and_discard_err(RUNTIME.block_on(async { client.set_activity(activity).await }))?; + Ok(()) +} + +fn jobject_to_activity(env: JNIEnv, jobject: JObject) -> Result { + let j_state = env.get_field(jobject, "state", SIG_STRING).map_err(|_| ())?; + let j_details = env.get_field(jobject, "details", SIG_STRING).map_err(|_| ())?; + let j_instance = env.get_field(jobject, "instance", SIG_BOOL).map_err(|_| ())?; + let j_timestamps = env.get_field(jobject, "timestamps", "Lcom/discord/rpc/ActivityTimestamps;").map_err(|_| ())?; + let j_assets = env.get_field(jobject, "assets", "Lcom/discord/rpc/ActivityAssets;").map_err(|_| ())?; + let j_party = env.get_field(jobject, "party", "Lcom/discord/rpc/ActivityParty;").map_err(|_| ())?; + let j_secrets = env.get_field(jobject, "secrets", "Lcom/discord/rpc/ActivitySecrets;").map_err(|_| ())?; + + let mut activity = drpc::models::Activity::new(); + if let JValue::Object(obj) = j_state { + if !is_null(env, obj).map_err(|_| ())? { + activity = activity.state(env.get_string(obj.into()).map_err(|_| ())?); + } + } + if let JValue::Object(obj) = j_details { + if !is_null(env, obj).map_err(|_| ())? { + activity = activity.details(env.get_string(obj.into()).map_err(|_| ())?); + } + } + if let JValue::Bool(b) = j_instance { + if b != 0 { + activity = activity.instance(true); + } + } + if let JValue::Object(obj) = j_timestamps { + if !is_null(env, obj).map_err(|_| ())? { + let timestamps = jobject_to_activity_timestamps(env, obj)?; + activity = activity.timestamps(|_|timestamps); + } + } + if let JValue::Object(obj) = j_assets { + if !is_null(env, obj).map_err(|_| ())? { + let assets = jobject_to_activity_assets(env, obj)?; + activity = activity.assets(|_|assets); + } + } + if let JValue::Object(obj) = j_party { + if !is_null(env, obj).map_err(|_| ())? { + let party = jobject_to_activity_party(env, obj)?; + activity = activity.party(|_|party); + } + } + if let JValue::Object(obj) = j_secrets { + if !is_null(env, obj).map_err(|_| ())? { + let secrets = jobject_to_activity_secrets(env, obj)?; + activity = activity.secrets(|_|secrets); + } + } + + Ok(activity) +} + +fn jobject_to_activity_timestamps(env: JNIEnv, jobject: JObject) -> Result { + let j_start = debug_and_discard_err(env.get_field(jobject, "start", SIG_NULLABLE_LONG))?; + let j_end = debug_and_discard_err(env.get_field(jobject, "end", SIG_NULLABLE_LONG))?; + + let mut timestamps = drpc::models::ActivityTimestamps::new(); + if let JValue::Object(obj) = j_start { + if !is_null(env, obj).map_err(|_| ())? { + if let JValue::Long(l) = debug_and_discard_err(env.call_method_unchecked(obj, (obj, "longValue", "()J"), JavaType::Primitive(Primitive::Long), &[]))? { + timestamps = timestamps.start(l as u64); + } + } + } + if let JValue::Object(obj) = j_end { + if !is_null(env, obj).map_err(|_| ())? { + if let JValue::Long(l) = debug_and_discard_err(env.call_method_unchecked(obj, (obj, "longValue", "()J"), JavaType::Primitive(Primitive::Long), &[]))? { + timestamps = timestamps.end(l as u64); + } + } + } + + Ok(timestamps) +} + +fn jobject_to_activity_assets(env: JNIEnv, jobject: JObject) -> Result { + let j_lrg_img = env.get_field(jobject, "largeImage", SIG_STRING).map_err(|_| ())?; + let j_lrg_txt = env.get_field(jobject, "largeText", SIG_STRING).map_err(|_| ())?; + let j_sml_img = env.get_field(jobject, "smallImage", SIG_STRING).map_err(|_| ())?; + let j_sml_txt = env.get_field(jobject, "smallText", SIG_STRING).map_err(|_| ())?; + + let mut assets = drpc::models::ActivityAssets::new(); + if let JValue::Object(obj) = j_lrg_img { + if !is_null(env, obj).map_err(|_| ())? { + assets = assets.large_image(env.get_string(obj.into()).map_err(|_| ())?); + } + } + if let JValue::Object(obj) = j_lrg_txt { + if !is_null(env, obj).map_err(|_| ())? { + assets = assets.large_text(env.get_string(obj.into()).map_err(|_| ())?); + } + } + if let JValue::Object(obj) = j_sml_img { + if !is_null(env, obj).map_err(|_| ())? { + assets = assets.small_image(env.get_string(obj.into()).map_err(|_| ())?); + } + } + if let JValue::Object(obj) = j_sml_txt { + if !is_null(env, obj).map_err(|_| ())? { + assets = assets.small_text(env.get_string(obj.into()).map_err(|_| ())?); + } + } + + Ok(assets) +} + +fn jobject_to_activity_party(env: JNIEnv, jobject: JObject) -> Result { + let j_id = env.get_field(jobject, "id", SIG_INT).map_err(|_| ())?; + let j_min_size = env.get_field(jobject, "minSize", SIG_INT).map_err(|_| ())?; + let j_max_size = env.get_field(jobject, "maxSize", SIG_INT).map_err(|_| ())?; + + let mut party = drpc::models::ActivityParty::new(); + if let JValue::Int(l) = j_id { + party = party.id(l as u32); + } + if let (JValue::Int(l1), JValue::Int(l2)) = (j_min_size, j_max_size) { + party = party.size((l1 as u32, l2 as u32)); + } + + Ok(party) +} + +fn jobject_to_activity_secrets(env: JNIEnv, jobject: JObject) -> Result { + let j_join = env.get_field(jobject, "join", SIG_STRING).map_err(|_| ())?; + let j_spectate = env.get_field(jobject, "spectate", SIG_STRING).map_err(|_| ())?; + let j_game = env.get_field(jobject, "game", SIG_STRING).map_err(|_| ())?; + + let mut secrets = drpc::models::ActivitySecrets::new(); + if let JValue::Object(obj) = j_join { + if !is_null(env, obj).map_err(|_| ())? { + secrets = secrets.join(env.get_string(obj.into()).map_err(|_| ())?); + } + } + if let JValue::Object(obj) = j_spectate { + if !is_null(env, obj).map_err(|_| ())? { + secrets = secrets.spectate(env.get_string(obj.into()).map_err(|_| ())?); + } + } + if let JValue::Object(obj) = j_game { + if !is_null(env, obj).map_err(|_| ())? { + secrets = secrets.game(env.get_string(obj.into()).map_err(|_| ())?); + } + } + + Ok(secrets) +} + diff --git a/src/lib.rs b/src/lib.rs index c6be4bf..f58231c 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -17,3 +17,6 @@ pub use client::Client; pub use connection::{Connection, SocketConnection}; pub use error::*; + +#[cfg(feature = "java-bindings")] +pub mod java;