diff --git a/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/model/PlayPurchase.kt b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/model/PlayPurchase.kt new file mode 100644 index 000000000000..8ae46a07a9b6 --- /dev/null +++ b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/model/PlayPurchase.kt @@ -0,0 +1,6 @@ +package net.mullvad.mullvadvpn.model + +import android.os.Parcelable +import kotlinx.parcelize.Parcelize + +@Parcelize data class PlayPurchase(val productId: String, val purchaseToken: String) : Parcelable diff --git a/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/model/PlayPurchaseInitError.kt b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/model/PlayPurchaseInitError.kt new file mode 100644 index 000000000000..39aebabbe260 --- /dev/null +++ b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/model/PlayPurchaseInitError.kt @@ -0,0 +1,10 @@ +package net.mullvad.mullvadvpn.model + +import android.os.Parcelable +import kotlinx.parcelize.Parcelize + +@Parcelize +enum class PlayPurchaseInitError : Parcelable { + // TODO: Add more errors here. + OtherError +} diff --git a/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/model/PlayPurchaseInitResult.kt b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/model/PlayPurchaseInitResult.kt new file mode 100644 index 000000000000..41407474af7c --- /dev/null +++ b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/model/PlayPurchaseInitResult.kt @@ -0,0 +1,10 @@ +package net.mullvad.mullvadvpn.model + +import android.os.Parcelable +import kotlinx.parcelize.Parcelize + +sealed class PlayPurchaseInitResult : Parcelable { + @Parcelize data class Ok(val obfuscatedId: String) : PlayPurchaseInitResult() + + @Parcelize data class Error(val error: PlayPurchaseInitError) : PlayPurchaseInitResult() +} diff --git a/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/model/PlayPurchaseVerifyError.kt b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/model/PlayPurchaseVerifyError.kt new file mode 100644 index 000000000000..b0434c22f969 --- /dev/null +++ b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/model/PlayPurchaseVerifyError.kt @@ -0,0 +1,10 @@ +package net.mullvad.mullvadvpn.model + +import android.os.Parcelable +import kotlinx.parcelize.Parcelize + +@Parcelize +enum class PlayPurchaseVerifyError : Parcelable { + // TODO: Add more errors here. + OtherError +} diff --git a/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/model/PlayPurchaseVerifyResult.kt b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/model/PlayPurchaseVerifyResult.kt new file mode 100644 index 000000000000..7c5ee4d95314 --- /dev/null +++ b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/model/PlayPurchaseVerifyResult.kt @@ -0,0 +1,10 @@ +package net.mullvad.mullvadvpn.model + +import android.os.Parcelable +import kotlinx.parcelize.Parcelize + +sealed class PlayPurchaseVerifyResult : Parcelable { + @Parcelize data object Ok : PlayPurchaseVerifyResult() + + @Parcelize data class Error(val error: PlayPurchaseVerifyError) : PlayPurchaseVerifyResult() +} diff --git a/android/service/src/main/kotlin/net/mullvad/mullvadvpn/service/MullvadDaemon.kt b/android/service/src/main/kotlin/net/mullvad/mullvadvpn/service/MullvadDaemon.kt index 6fa03978f774..ceb95a48b751 100644 --- a/android/service/src/main/kotlin/net/mullvad/mullvadvpn/service/MullvadDaemon.kt +++ b/android/service/src/main/kotlin/net/mullvad/mullvadvpn/service/MullvadDaemon.kt @@ -14,6 +14,9 @@ import net.mullvad.mullvadvpn.model.GeoIpLocation import net.mullvad.mullvadvpn.model.GetAccountDataResult import net.mullvad.mullvadvpn.model.LoginResult import net.mullvad.mullvadvpn.model.ObfuscationSettings +import net.mullvad.mullvadvpn.model.PlayPurchase +import net.mullvad.mullvadvpn.model.PlayPurchaseInitResult +import net.mullvad.mullvadvpn.model.PlayPurchaseVerifyResult import net.mullvad.mullvadvpn.model.QuantumResistantState import net.mullvad.mullvadvpn.model.RelayList import net.mullvad.mullvadvpn.model.RelaySettingsUpdate @@ -171,6 +174,14 @@ class MullvadDaemon( return submitVoucher(daemonInterfaceAddress, voucher) } + fun initPlayPurchase(): PlayPurchaseInitResult { + return initPlayPurchase(daemonInterfaceAddress) + } + + fun verifyPlayPurchase(playPurchase: PlayPurchase): PlayPurchaseVerifyResult { + return verifyPlayPurchase(daemonInterfaceAddress, playPurchase) + } + fun updateRelaySettings(update: RelaySettingsUpdate) { updateRelaySettings(daemonInterfaceAddress, update) } @@ -271,6 +282,13 @@ class MullvadDaemon( voucher: String ): VoucherSubmissionResult + private external fun initPlayPurchase(daemonInterfaceAddress: Long): PlayPurchaseInitResult + + private external fun verifyPlayPurchase( + daemonInterfaceAddress: Long, + playPurchase: PlayPurchase, + ): PlayPurchaseVerifyResult + private external fun updateRelaySettings( daemonInterfaceAddress: Long, update: RelaySettingsUpdate diff --git a/mullvad-api/src/lib.rs b/mullvad-api/src/lib.rs index 37faf5c40ceb..63f5c2ad5b46 100644 --- a/mullvad-api/src/lib.rs +++ b/mullvad-api/src/lib.rs @@ -5,6 +5,8 @@ use chrono::{offset::Utc, DateTime}; use futures::channel::mpsc; use futures::Stream; use hyper::Method; +#[cfg(target_os = "android")] +use mullvad_types::account::{PlayPurchase, PlayPurchasePaymentToken}; use mullvad_types::{ account::{AccountToken, VoucherSubmission}, version::AppVersion, @@ -63,6 +65,8 @@ pub const API_IP_CACHE_FILENAME: &str = "api-ip-address.txt"; const ACCOUNTS_URL_PREFIX: &str = "accounts/v1"; const APP_URL_PREFIX: &str = "app/v1"; +#[cfg(target_os = "android")] +const GOOGLE_PAYMENTS_URL_PREFIX: &str = "payments/google-play/v1"; pub static API: LazyManual = LazyManual::new(ApiEndpoint::from_env_vars); @@ -457,6 +461,64 @@ impl AccountsProxy { } } + #[cfg(target_os = "android")] + pub fn init_play_purchase( + &mut self, + account_token: AccountToken, + ) -> impl Future> { + #[derive(serde::Deserialize)] + struct PlayPurchaseInitResponse { + obfuscated_id: String, + } + + let service = self.handle.service.clone(); + let factory = self.handle.factory.clone(); + let access_proxy = self.handle.token_store.clone(); + + async move { + let response = rest::send_json_request( + &factory, + service, + &format!("{GOOGLE_PAYMENTS_URL_PREFIX}/init"), + Method::POST, + &(), + Some((access_proxy, account_token)), + &[StatusCode::OK], + ) + .await; + + let PlayPurchaseInitResponse { obfuscated_id } = + rest::deserialize_body(response?).await?; + + Ok(obfuscated_id) + } + } + + #[cfg(target_os = "android")] + pub fn verify_play_purchase( + &mut self, + account_token: AccountToken, + play_purchase: PlayPurchase, + ) -> impl Future> { + let service = self.handle.service.clone(); + let factory = self.handle.factory.clone(); + let access_proxy = self.handle.token_store.clone(); + + async move { + rest::send_json_request( + &factory, + service, + &format!("{GOOGLE_PAYMENTS_URL_PREFIX}/acknowledge"), + Method::POST, + &play_purchase, + Some((access_proxy, account_token)), + &[StatusCode::ACCEPTED], + ) + .await?; + Ok(()) + } + } + pub fn get_www_auth_token( &self, account: AccountToken, diff --git a/mullvad-api/src/rest.rs b/mullvad-api/src/rest.rs index 674bcf8c4eb0..c3687a1eee9d 100644 --- a/mullvad-api/src/rest.rs +++ b/mullvad-api/src/rest.rs @@ -394,10 +394,17 @@ impl From for RestRequest { } #[derive(serde::Deserialize)] -pub struct ErrorResponse { +struct OldErrorResponse { pub code: String, } +/// If `NewErrorResponse::type` is not defined it should default to "about:blank" +const DEFAULT_ERROR_TYPE: &str = "about:blank"; +#[derive(serde::Deserialize)] +struct NewErrorResponse { + pub r#type: Option, +} + #[derive(Clone)] pub struct RequestFactory { hostname: String, @@ -600,8 +607,27 @@ pub async fn handle_error_response(response: Response) -> Result { status => match get_body_length(&response) { 0 => status.canonical_reason().unwrap_or("Unexpected error"), body_length => { - let err: ErrorResponse = deserialize_body_inner(response, body_length).await?; - return Err(Error::ApiError(status, err.code)); + return match response.headers().get("content-type") { + Some(content_type) if content_type == "application/problem+json" => { + // TODO: We should make sure we unify the new error format and the old + // error format so that they both produce the same Errors for the same + // problems after being processed. + let err: NewErrorResponse = + deserialize_body_inner(response, body_length).await?; + // The new error type replaces the `code` field with the `type` field. + // This is what is used to programmatically check the error. + Err(Error::ApiError( + status, + err.r#type + .unwrap_or_else(|| String::from(DEFAULT_ERROR_TYPE)), + )) + } + _ => { + let err: OldErrorResponse = + deserialize_body_inner(response, body_length).await?; + Err(Error::ApiError(status, err.code)) + } + }; } }, }; diff --git a/mullvad-daemon/src/device/api.rs b/mullvad-daemon/src/device/api.rs index 93dae7826158..d7b73d6e7beb 100644 --- a/mullvad-daemon/src/device/api.rs +++ b/mullvad-daemon/src/device/api.rs @@ -2,6 +2,8 @@ use std::pin::Pin; use chrono::{DateTime, Utc}; use futures::{future::FusedFuture, Future}; +#[cfg(target_os = "android")] +use mullvad_types::account::PlayPurchasePaymentToken; use mullvad_types::{account::VoucherSubmission, device::Device, wireguard::WireguardData}; use super::{Error, PrivateAccountAndDevice, ResponseTx}; @@ -47,6 +49,27 @@ impl CurrentApiCall { self.current_call = Some(Call::VoucherSubmission(voucher_call, Some(tx))); } + #[cfg(target_os = "android")] + pub fn set_init_play_purchase( + &mut self, + init_play_purchase_call: ApiCall, + tx: ResponseTx, + ) { + self.current_call = Some(Call::InitPlayPurchase(init_play_purchase_call, Some(tx))); + } + + #[cfg(target_os = "android")] + pub fn set_verify_play_purchase( + &mut self, + verify_play_purchase_call: ApiCall<()>, + tx: ResponseTx<()>, + ) { + self.current_call = Some(Call::VerifyPlayPurchase( + verify_play_purchase_call, + Some(tx), + )); + } + pub fn is_validating(&self) -> bool { matches!( &self.current_call, @@ -109,6 +132,13 @@ enum Call { ApiCall, Option>, ), + #[cfg(target_os = "android")] + InitPlayPurchase( + ApiCall, + Option>, + ), + #[cfg(target_os = "android")] + VerifyPlayPurchase(ApiCall<()>, Option>), ExpiryCheck(ApiCall>), } @@ -142,6 +172,28 @@ impl futures::Future for Call { std::task::Poll::Pending } } + #[cfg(target_os = "android")] + InitPlayPurchase(call, tx) => { + if let std::task::Poll::Ready(response) = Pin::new(call).poll(cx) { + std::task::Poll::Ready(ApiResult::InitPlayPurchase( + response, + tx.take().unwrap(), + )) + } else { + std::task::Poll::Pending + } + } + #[cfg(target_os = "android")] + VerifyPlayPurchase(call, tx) => { + if let std::task::Poll::Ready(response) = Pin::new(call).poll(cx) { + std::task::Poll::Ready(ApiResult::VerifyPlayPurchase( + response, + tx.take().unwrap(), + )) + } else { + std::task::Poll::Pending + } + } ExpiryCheck(call) => Pin::new(call).poll(cx).map(ApiResult::ExpiryCheck), } } @@ -155,5 +207,12 @@ pub(crate) enum ApiResult { Result, ResponseTx, ), + #[cfg(target_os = "android")] + InitPlayPurchase( + Result, + ResponseTx, + ), + #[cfg(target_os = "android")] + VerifyPlayPurchase(Result<(), Error>, ResponseTx<()>), ExpiryCheck(Result, Error>), } diff --git a/mullvad-daemon/src/device/mod.rs b/mullvad-daemon/src/device/mod.rs index b54638f1404e..df5606a14375 100644 --- a/mullvad-daemon/src/device/mod.rs +++ b/mullvad-daemon/src/device/mod.rs @@ -5,6 +5,8 @@ use futures::{ }; use mullvad_api::rest; +#[cfg(target_os = "android")] +use mullvad_types::account::{PlayPurchase, PlayPurchasePaymentToken}; use mullvad_types::{ account::{AccountToken, VoucherSubmission}, device::{ @@ -12,6 +14,7 @@ use mullvad_types::{ }, wireguard::{self, RotationInterval, WireguardData}, }; + use std::{ future::Future, path::Path, @@ -305,6 +308,10 @@ enum AccountManagerCommand { SetRotationInterval(RotationInterval, ResponseTx<()>), ValidateDevice(ResponseTx<()>), SubmitVoucher(String, ResponseTx), + #[cfg(target_os = "android")] + InitPlayPurchase(ResponseTx), + #[cfg(target_os = "android")] + VerifyPlayPurchase(ResponseTx<()>, PlayPurchase), CheckExpiry(ResponseTx>), Shutdown(oneshot::Sender<()>), } @@ -363,6 +370,18 @@ impl AccountManagerHandle { self.send_command(AccountManagerCommand::CheckExpiry).await } + #[cfg(target_os = "android")] + pub async fn init_play_purchase(&self) -> Result { + self.send_command(AccountManagerCommand::InitPlayPurchase) + .await + } + + #[cfg(target_os = "android")] + pub async fn verify_play_purchase(&self, play_purchase: PlayPurchase) -> Result<(), Error> { + self.send_command(move |tx| AccountManagerCommand::VerifyPlayPurchase(tx, play_purchase)) + .await + } + pub async fn shutdown(self) { let (tx, rx) = oneshot::channel(); let _ = self @@ -517,6 +536,14 @@ impl AccountManager { Some(AccountManagerCommand::CheckExpiry(tx)) => { self.handle_expiry_request(tx, &mut current_api_call); }, + #[cfg(target_os = "android")] + Some(AccountManagerCommand::InitPlayPurchase(tx)) => { + self.handle_init_play_purchase(tx, &mut current_api_call); + }, + #[cfg(target_os = "android")] + Some(AccountManagerCommand::VerifyPlayPurchase(tx, play_purchase)) => { + self.handle_verify_play_purchase(tx, play_purchase, &mut current_api_call); + }, None => { break; @@ -589,6 +616,34 @@ impl AccountManager { } } + #[cfg(target_os = "android")] + fn handle_init_play_purchase( + &mut self, + tx: ResponseTx, + current_api_call: &mut api::CurrentApiCall, + ) { + if current_api_call.is_logging_in() { + let _ = tx.send(Err(Error::AccountChange)); + return; + } + + let init_play_purchase_api_call = move || { + let old_config = self.data.device().ok_or(Error::NoDevice)?; + let account_token = old_config.account_token.clone(); + let account_service = self.account_service.clone(); + Ok(async move { account_service.init_play_purchase(account_token).await }) + }; + + match init_play_purchase_api_call() { + Ok(call) => { + current_api_call.set_init_play_purchase(Box::pin(call), tx); + } + Err(err) => { + let _ = tx.send(Err(err)); + } + } + } + fn handle_expiry_request( &mut self, tx: ResponseTx>, @@ -614,6 +669,39 @@ impl AccountManager { } } + #[cfg(target_os = "android")] + fn handle_verify_play_purchase( + &mut self, + tx: ResponseTx<()>, + play_purchase: PlayPurchase, + current_api_call: &mut api::CurrentApiCall, + ) { + if current_api_call.is_logging_in() { + let _ = tx.send(Err(Error::AccountChange)); + return; + } + + let play_purchase_verify_api_call = move || { + let old_config = self.data.device().ok_or(Error::NoDevice)?; + let account_token = old_config.account_token.clone(); + let account_service = self.account_service.clone(); + Ok(async move { + account_service + .verify_play_purchase(account_token, play_purchase) + .await + }) + }; + + match play_purchase_verify_api_call() { + Ok(call) => { + current_api_call.set_verify_play_purchase(Box::pin(call), tx); + } + Err(err) => { + let _ = tx.send(Err(err)); + } + } + } + async fn consume_api_result( &mut self, result: api::ApiResult, @@ -628,6 +716,16 @@ impl AccountManager { self.consume_voucher_result(data_response, tx).await } ExpiryCheck(data_response) => self.consume_expiry_result(data_response).await, + #[cfg(target_os = "android")] + InitPlayPurchase(data_response, tx) => { + self.consume_init_play_purchase_result(data_response, tx) + .await + } + #[cfg(target_os = "android")] + VerifyPlayPurchase(data_response, tx) => { + self.consume_verify_play_purchase_result(data_response, tx) + .await + } } } @@ -799,6 +897,44 @@ impl AccountManager { } } + #[cfg(target_os = "android")] + async fn consume_init_play_purchase_result( + &mut self, + response: Result, + tx: ResponseTx, + ) { + match &response { + Ok(_) => (), + Err(Error::InvalidAccount) => { + self.revoke_device(|| Error::InvalidAccount).await; + } + Err(Error::InvalidDevice) => { + self.revoke_device(|| Error::InvalidDevice).await; + } + Err(err) => log::error!("Failed to initialize play purchase: {}", err), + } + let _ = tx.send(response); + } + + #[cfg(target_os = "android")] + async fn consume_verify_play_purchase_result( + &mut self, + response: Result<(), Error>, + tx: ResponseTx<()>, + ) { + match &response { + Ok(_) => (), + Err(Error::InvalidAccount) => { + self.revoke_device(|| Error::InvalidAccount).await; + } + Err(Error::InvalidDevice) => { + self.revoke_device(|| Error::InvalidDevice).await; + } + Err(err) => log::error!("Failed to verify play purchase: {}", err), + } + let _ = tx.send(response); + } + fn drain_device_requests_with_err(&mut self, err: Error) { let cloneable_err = Arc::new(err); Self::drain_requests(&mut self.rotation_requests, || { diff --git a/mullvad-daemon/src/device/service.rs b/mullvad-daemon/src/device/service.rs index 9c967e441379..fdda61297fd0 100644 --- a/mullvad-daemon/src/device/service.rs +++ b/mullvad-daemon/src/device/service.rs @@ -2,6 +2,8 @@ use std::{future::Future, time::Duration}; use chrono::{DateTime, Utc}; use futures::future::{abortable, AbortHandle}; +#[cfg(target_os = "android")] +use mullvad_types::account::{PlayPurchase, PlayPurchasePaymentToken}; use mullvad_types::{ account::{AccountToken, VoucherSubmission}, device::{Device, DeviceId}, @@ -320,6 +322,47 @@ impl AccountService { } result.map_err(map_rest_error) } + + #[cfg(target_os = "android")] + pub async fn init_play_purchase( + &self, + account_token: AccountToken, + ) -> Result { + let mut proxy = self.proxy.clone(); + let api_handle = self.api_availability.clone(); + let result = retry_future( + move || proxy.init_play_purchase(account_token.clone()), + move |result| should_retry(result, &api_handle), + RETRY_ACTION_STRATEGY, + ) + .await; + if result.is_ok() { + self.initial_check_abort_handle.abort(); + self.api_availability.resume_background(); + } + result.map_err(map_rest_error) + } + + #[cfg(target_os = "android")] + pub async fn verify_play_purchase( + &self, + account_token: AccountToken, + play_purchase: PlayPurchase, + ) -> Result<(), Error> { + let mut proxy = self.proxy.clone(); + let api_handle = self.api_availability.clone(); + let result = retry_future( + move || proxy.verify_play_purchase(account_token.clone(), play_purchase.clone()), + move |result| should_retry(result, &api_handle), + RETRY_ACTION_STRATEGY, + ) + .await; + if result.is_ok() { + self.initial_check_abort_handle.abort(); + self.api_availability.resume_background(); + } + result.map_err(map_rest_error) + } } pub fn spawn_account_service( @@ -409,6 +452,7 @@ fn should_retry_backoff(result: &Result) -> bool { fn map_rest_error(error: rest::Error) -> Error { match error { RestError::ApiError(_status, ref code) => match code.as_str() { + // TODO: Implement invalid payment mullvad_api::DEVICE_NOT_FOUND => Error::InvalidDevice, mullvad_api::INVALID_ACCOUNT => Error::InvalidAccount, mullvad_api::MAX_DEVICES_REACHED => Error::MaxDevicesReached, diff --git a/mullvad-daemon/src/lib.rs b/mullvad-daemon/src/lib.rs index f7343acc877a..1077185ca38d 100644 --- a/mullvad-daemon/src/lib.rs +++ b/mullvad-daemon/src/lib.rs @@ -38,6 +38,8 @@ use mullvad_relay_selector::{ updater::{RelayListUpdater, RelayListUpdaterHandle}, RelaySelector, SelectorConfig, }; +#[cfg(target_os = "android")] +use mullvad_types::account::{PlayPurchase, PlayPurchasePaymentToken}; use mullvad_types::{ access_method::{AccessMethod, AccessMethodSetting}, account::{AccountData, AccountToken, VoucherSubmission}, @@ -178,6 +180,14 @@ pub enum Error { #[cfg(target_os = "macos")] #[error(display = "Failed to set exclusion group")] GroupIdError(#[error(source)] io::Error), + + #[cfg(target_os = "android")] + #[error(display = "Failed to initialize play purchase")] + InitPlayPurchase(#[error(source)] device::Error), + + #[cfg(target_os = "android")] + #[error(display = "Failed to verify play purchase")] + VerifyPlayPurchase(#[error(source)] device::Error), } /// Enum representing commands that can be sent to the daemon. @@ -327,6 +337,12 @@ pub enum DaemonCommand { /// to bypass the tunnel in blocking states. #[cfg(target_os = "android")] BypassSocket(RawFd, oneshot::Sender<()>), + /// Initialize a google play purchase through the API. + #[cfg(target_os = "android")] + InitPlayPurchase(ResponseTx), + /// Verify that a google play payment was successful through the API. + #[cfg(target_os = "android")] + VerifyPlayPurchase(ResponseTx<(), Error>, PlayPurchase), } /// All events that can happen in the daemon. Sent from various threads and exposed interfaces. @@ -1109,6 +1125,12 @@ where PrepareRestart => self.on_prepare_restart(), #[cfg(target_os = "android")] BypassSocket(fd, tx) => self.on_bypass_socket(fd, tx), + #[cfg(target_os = "android")] + InitPlayPurchase(tx) => self.on_init_play_purchase(tx), + #[cfg(target_os = "android")] + VerifyPlayPurchase(tx, play_purchase) => { + self.on_verify_play_purchase(tx, play_purchase) + } } } @@ -2370,6 +2392,36 @@ where } } + #[cfg(target_os = "android")] + fn on_init_play_purchase(&mut self, tx: ResponseTx) { + let manager = self.account_manager.clone(); + tokio::spawn(async move { + Self::oneshot_send( + tx, + manager + .init_play_purchase() + .await + .map_err(Error::InitPlayPurchase), + "init_play_purchase response", + ); + }); + } + + #[cfg(target_os = "android")] + fn on_verify_play_purchase(&mut self, tx: ResponseTx<(), Error>, play_purchase: PlayPurchase) { + let manager = self.account_manager.clone(); + tokio::spawn(async move { + Self::oneshot_send( + tx, + manager + .verify_play_purchase(play_purchase) + .await + .map_err(Error::VerifyPlayPurchase), + "verify_play_purchase response", + ); + }); + } + /// Set the target state of the client. If it changed trigger the operations needed to /// progress towards that state. /// Returns a bool representing whether or not a state change was initiated. diff --git a/mullvad-jni/src/classes.rs b/mullvad-jni/src/classes.rs index 88b5f6d9380d..56de8919db75 100644 --- a/mullvad-jni/src/classes.rs +++ b/mullvad-jni/src/classes.rs @@ -33,6 +33,11 @@ pub const CLASSES: &[&str] = &[ "net/mullvad/mullvadvpn/model/LocationConstraint$Location", "net/mullvad/mullvadvpn/model/LocationConstraint$CustomList", "net/mullvad/mullvadvpn/model/ObfuscationSettings", + "net/mullvad/mullvadvpn/model/PlayPurchase", + "net/mullvad/mullvadvpn/model/PlayPurchaseInitError", + "net/mullvad/mullvadvpn/model/PlayPurchaseInitResult", + "net/mullvad/mullvadvpn/model/PlayPurchaseVerifyError", + "net/mullvad/mullvadvpn/model/PlayPurchaseVerifyResult", "net/mullvad/mullvadvpn/model/PublicKey", "net/mullvad/mullvadvpn/model/QuantumResistantState", "net/mullvad/mullvadvpn/model/Port", diff --git a/mullvad-jni/src/daemon_interface.rs b/mullvad-jni/src/daemon_interface.rs index 771c432a7a50..c64c94041e62 100644 --- a/mullvad-jni/src/daemon_interface.rs +++ b/mullvad-jni/src/daemon_interface.rs @@ -1,7 +1,7 @@ use futures::{channel::oneshot, executor::block_on}; use mullvad_daemon::{device, DaemonCommand, DaemonCommandSender}; use mullvad_types::{ - account::{AccountData, AccountToken, VoucherSubmission}, + account::{AccountData, AccountToken, PlayPurchase, VoucherSubmission}, device::{Device, DeviceState}, location::GeoIpLocation, relay_constraints::{ObfuscationSettings, RelaySettingsUpdate}, @@ -307,6 +307,26 @@ impl DaemonInterface { .map_err(Error::from) } + pub fn init_play_purchase(&self) -> Result { + let (tx, rx) = oneshot::channel(); + + self.send_command(DaemonCommand::InitPlayPurchase(tx))?; + + block_on(rx) + .map_err(|_| Error::NoResponse)? + .map_err(Error::from) + } + + pub fn verify_play_purchase(&self, play_purchase: PlayPurchase) -> Result<()> { + let (tx, rx) = oneshot::channel(); + + self.send_command(DaemonCommand::VerifyPlayPurchase(tx, play_purchase))?; + + block_on(rx) + .map_err(|_| Error::NoResponse)? + .map_err(Error::from) + } + pub fn update_relay_settings(&self, update: RelaySettingsUpdate) -> Result<()> { let (tx, rx) = oneshot::channel(); diff --git a/mullvad-jni/src/lib.rs b/mullvad-jni/src/lib.rs index 6f33938e993d..a40bf73fc13f 100644 --- a/mullvad-jni/src/lib.rs +++ b/mullvad-jni/src/lib.rs @@ -24,7 +24,7 @@ use mullvad_daemon::{ DaemonCommandChannel, }; use mullvad_types::{ - account::{AccountData, VoucherSubmission}, + account::{AccountData, PlayPurchase, VoucherSubmission}, settings::DnsOptions, }; use std::{ @@ -191,6 +191,62 @@ impl From for VoucherSubmissionError { } } +#[derive(IntoJava)] +#[jnix(package = "net.mullvad.mullvadvpn.model")] +pub enum PlayPurchaseInitResult { + Ok(String), + Error(PlayPurchaseInitError), +} + +#[derive(IntoJava)] +#[jnix(package = "net.mullvad.mullvadvpn.model")] +pub enum PlayPurchaseInitError { + OtherError, +} + +impl From> for PlayPurchaseInitResult { + fn from(result: Result) -> Self { + match result { + Ok(obfuscated_id) => PlayPurchaseInitResult::Ok(obfuscated_id), + Err(error) => PlayPurchaseInitResult::Error(error.into()), + } + } +} + +impl From for PlayPurchaseInitError { + fn from(_error: daemon_interface::Error) -> Self { + PlayPurchaseInitError::OtherError + } +} + +#[derive(IntoJava)] +#[jnix(package = "net.mullvad.mullvadvpn.model")] +pub enum PlayPurchaseVerifyResult { + Ok, + Error(PlayPurchaseVerifyError), +} + +#[derive(IntoJava)] +#[jnix(package = "net.mullvad.mullvadvpn.model")] +pub enum PlayPurchaseVerifyError { + OtherError, +} + +impl From> for PlayPurchaseVerifyResult { + fn from(result: Result<(), daemon_interface::Error>) -> Self { + match result { + Ok(()) => PlayPurchaseVerifyResult::Ok, + Err(error) => PlayPurchaseVerifyResult::Error(error.into()), + } + } +} + +impl From for PlayPurchaseVerifyError { + fn from(_error: daemon_interface::Error) -> Self { + PlayPurchaseVerifyError::OtherError + } +} + #[no_mangle] #[allow(non_snake_case)] pub extern "system" fn Java_net_mullvad_mullvadvpn_service_MullvadDaemon_initialize( @@ -1192,6 +1248,62 @@ pub extern "system" fn Java_net_mullvad_mullvadvpn_service_MullvadDaemon_submitV result.into_java(&env).forget() } +#[no_mangle] +#[allow(non_snake_case)] +pub extern "system" fn Java_net_mullvad_mullvadvpn_service_MullvadDaemon_initPlayPurchase<'env>( + env: JNIEnv<'env>, + _: JObject<'_>, + daemon_interface_address: jlong, +) -> JObject<'env> { + let env = JnixEnv::from(env); + + let result = + // SAFETY: The address points to an instance valid for the duration of this function call + if let Some(daemon_interface) = unsafe { get_daemon_interface(daemon_interface_address) } { + let raw_result = daemon_interface.init_play_purchase(); + + if let Err(ref error) = &raw_result { + log_request_error("init google play purchase", error); + } + + PlayPurchaseInitResult::from(raw_result) + } else { + PlayPurchaseInitResult::Error(PlayPurchaseInitError::OtherError) + }; + + result.into_java(&env).forget() +} + +#[no_mangle] +#[allow(non_snake_case)] +pub extern "system" fn Java_net_mullvad_mullvadvpn_service_MullvadDaemon_verifyPlayPurchase< + 'env, +>( + env: JNIEnv<'env>, + _: JObject<'_>, + daemon_interface_address: jlong, + play_purchase: JObject<'_>, +) -> JObject<'env> { + let env = JnixEnv::from(env); + + let result = + // SAFETY: The address points to an instance valid for the duration of this function call + if let Some(daemon_interface) = unsafe { get_daemon_interface(daemon_interface_address) } { + let play_purchase = PlayPurchase::from_java(&env, play_purchase); + let raw_result = daemon_interface.verify_play_purchase(play_purchase); + + if let Err(ref error) = &raw_result { + log_request_error("verify google play purchase", error); + } + + PlayPurchaseVerifyResult::from(raw_result) + } else { + PlayPurchaseVerifyResult::Error(PlayPurchaseVerifyError::OtherError) + }; + + result.into_java(&env).forget() +} + #[no_mangle] #[allow(non_snake_case)] pub extern "system" fn Java_net_mullvad_mullvadvpn_service_MullvadDaemon_updateRelaySettings( diff --git a/mullvad-types/src/account.rs b/mullvad-types/src/account.rs index 16f6a963f24c..7adb7fbffa80 100644 --- a/mullvad-types/src/account.rs +++ b/mullvad-types/src/account.rs @@ -1,6 +1,6 @@ use chrono::{offset::Utc, DateTime}; #[cfg(target_os = "android")] -use jnix::IntoJava; +use jnix::{FromJava, IntoJava}; use serde::{Deserialize, Serialize}; /// Identifier used to identify a Mullvad account. @@ -9,6 +9,11 @@ pub type AccountToken = String; /// Identifier used to authenticate a Mullvad account. pub type AccessToken = String; +/// The payment token returned by initiating a google play purchase. +/// In the API this is called the `obfuscated_id`. +#[cfg(target_os = "android")] +pub type PlayPurchasePaymentToken = String; + /// Account expiration info returned by the API via `/v1/me`. #[derive(Debug, Clone, Eq, PartialEq, Hash, Serialize, Deserialize)] #[cfg_attr(target_os = "android", derive(IntoJava))] @@ -39,6 +44,16 @@ pub struct VoucherSubmission { pub new_expiry: DateTime, } +/// `PlayPurchase` is provided to google in order to verify that a google play purchase was acknowledged. +#[derive(Deserialize, Serialize, Debug, Clone)] +#[cfg_attr(target_os = "android", derive(FromJava))] +#[cfg_attr(target_os = "android", jnix(package = "net.mullvad.mullvadvpn.model"))] +#[cfg(target_os = "android")] +pub struct PlayPurchase { + pub product_id: String, + pub purchase_token: PlayPurchasePaymentToken, +} + /// Token used for authentication in the API. #[derive(Debug, Clone, Eq, PartialEq, Hash, Serialize, Deserialize)] pub struct AccessTokenData {