mirror of
https://github.com/chatmail/core.git
synced 2026-05-07 08:56:30 +03:00
feat: Send avatars as large as usual images when possible
- Remove limits on the avatar width and height, having a limit on its weight is sufficient. - Send avatars as large as usual images in protected chats, Outlook servers can't see avatars there anyway (they don't allow headers larger than 32k).
This commit is contained in:
104
src/blob.rs
104
src/blob.rs
@@ -1,6 +1,6 @@
|
|||||||
//! # Blob directory management.
|
//! # Blob directory management.
|
||||||
|
|
||||||
use core::cmp::max;
|
use std::cmp::{max, min};
|
||||||
use std::ffi::OsStr;
|
use std::ffi::OsStr;
|
||||||
use std::fmt;
|
use std::fmt;
|
||||||
use std::io::{Cursor, Seek};
|
use std::io::{Cursor, Seek};
|
||||||
@@ -14,13 +14,10 @@ use futures::StreamExt;
|
|||||||
use image::codecs::jpeg::JpegEncoder;
|
use image::codecs::jpeg::JpegEncoder;
|
||||||
use image::ImageReader;
|
use image::ImageReader;
|
||||||
use image::{DynamicImage, GenericImage, GenericImageView, ImageFormat, Pixel, Rgba};
|
use image::{DynamicImage, GenericImage, GenericImageView, ImageFormat, Pixel, Rgba};
|
||||||
use num_traits::FromPrimitive;
|
|
||||||
use tokio::io::AsyncWriteExt;
|
use tokio::io::AsyncWriteExt;
|
||||||
use tokio::{fs, io};
|
use tokio::{fs, io};
|
||||||
use tokio_stream::wrappers::ReadDirStream;
|
use tokio_stream::wrappers::ReadDirStream;
|
||||||
|
|
||||||
use crate::config::Config;
|
|
||||||
use crate::constants::{self, MediaQuality};
|
|
||||||
use crate::context::Context;
|
use crate::context::Context;
|
||||||
use crate::events::EventType;
|
use crate::events::EventType;
|
||||||
use crate::log::LogExt;
|
use crate::log::LogExt;
|
||||||
@@ -353,28 +350,33 @@ impl<'a> BlobObject<'a> {
|
|||||||
Ok(blob.as_name().to_string())
|
Ok(blob.as_name().to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn recode_to_avatar_size(&mut self, context: &Context) -> Result<()> {
|
/// Recodes an avatar pointed by a [BlobObject] so that it fits into limits on the image width,
|
||||||
|
/// height and file size specified by the config.
|
||||||
|
///
|
||||||
|
/// * `protected`: Whether the resulting avatar is going to be used in a protected context,
|
||||||
|
/// i.e. in a protected chat or stored locally, and therefore may be larger.
|
||||||
|
pub async fn recode_to_avatar_size(
|
||||||
|
&mut self,
|
||||||
|
context: &Context,
|
||||||
|
protected: bool,
|
||||||
|
) -> Result<()> {
|
||||||
let blob_abs = self.to_abs_path();
|
let blob_abs = self.to_abs_path();
|
||||||
|
let (img_wh, max_bytes) = context.max_image_wh_and_size().await?;
|
||||||
let img_wh =
|
|
||||||
match MediaQuality::from_i32(context.get_config_int(Config::MediaQuality).await?)
|
|
||||||
.unwrap_or_default()
|
|
||||||
{
|
|
||||||
MediaQuality::Balanced => constants::BALANCED_AVATAR_SIZE,
|
|
||||||
MediaQuality::Worse => constants::WORSE_AVATAR_SIZE,
|
|
||||||
};
|
|
||||||
|
|
||||||
let maybe_sticker = &mut false;
|
|
||||||
let strict_limits = true;
|
|
||||||
// max_bytes is 20_000 bytes: Outlook servers don't allow headers larger than 32k.
|
// max_bytes is 20_000 bytes: Outlook servers don't allow headers larger than 32k.
|
||||||
// 32 / 4 * 3 = 24k if you account for base64 encoding. To be safe, we reduced this to 20k.
|
// 32 / 4 * 3 = 24k if you account for base64 encoding. To be safe, we reduced this to 20k.
|
||||||
|
let max_bytes = match protected {
|
||||||
|
false => min(max_bytes, 20_000),
|
||||||
|
true => max_bytes,
|
||||||
|
};
|
||||||
|
let maybe_sticker = &mut false;
|
||||||
|
let is_avatar = true;
|
||||||
if let Some(new_name) = self.recode_to_size(
|
if let Some(new_name) = self.recode_to_size(
|
||||||
context,
|
context,
|
||||||
blob_abs,
|
blob_abs,
|
||||||
maybe_sticker,
|
maybe_sticker,
|
||||||
img_wh,
|
img_wh,
|
||||||
20_000,
|
max_bytes,
|
||||||
strict_limits,
|
is_avatar,
|
||||||
)? {
|
)? {
|
||||||
self.name = new_name;
|
self.name = new_name;
|
||||||
}
|
}
|
||||||
@@ -394,32 +396,21 @@ impl<'a> BlobObject<'a> {
|
|||||||
maybe_sticker: &mut bool,
|
maybe_sticker: &mut bool,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
let blob_abs = self.to_abs_path();
|
let blob_abs = self.to_abs_path();
|
||||||
let (img_wh, max_bytes) =
|
let (img_wh, max_bytes) = context.max_image_wh_and_size().await?;
|
||||||
match MediaQuality::from_i32(context.get_config_int(Config::MediaQuality).await?)
|
let is_avatar = false;
|
||||||
.unwrap_or_default()
|
|
||||||
{
|
|
||||||
MediaQuality::Balanced => (
|
|
||||||
constants::BALANCED_IMAGE_SIZE,
|
|
||||||
constants::BALANCED_IMAGE_BYTES,
|
|
||||||
),
|
|
||||||
MediaQuality::Worse => (constants::WORSE_IMAGE_SIZE, constants::WORSE_IMAGE_BYTES),
|
|
||||||
};
|
|
||||||
let strict_limits = false;
|
|
||||||
if let Some(new_name) = self.recode_to_size(
|
if let Some(new_name) = self.recode_to_size(
|
||||||
context,
|
context,
|
||||||
blob_abs,
|
blob_abs,
|
||||||
maybe_sticker,
|
maybe_sticker,
|
||||||
img_wh,
|
img_wh,
|
||||||
max_bytes,
|
max_bytes,
|
||||||
strict_limits,
|
is_avatar,
|
||||||
)? {
|
)? {
|
||||||
self.name = new_name;
|
self.name = new_name;
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// If `!strict_limits`, then if `max_bytes` is exceeded, reduce the image to `img_wh` and just
|
|
||||||
/// proceed with the result.
|
|
||||||
fn recode_to_size(
|
fn recode_to_size(
|
||||||
&mut self,
|
&mut self,
|
||||||
context: &Context,
|
context: &Context,
|
||||||
@@ -427,10 +418,10 @@ impl<'a> BlobObject<'a> {
|
|||||||
maybe_sticker: &mut bool,
|
maybe_sticker: &mut bool,
|
||||||
mut img_wh: u32,
|
mut img_wh: u32,
|
||||||
max_bytes: usize,
|
max_bytes: usize,
|
||||||
strict_limits: bool,
|
is_avatar: bool,
|
||||||
) -> Result<Option<String>> {
|
) -> Result<Option<String>> {
|
||||||
// Add white background only to avatars to spare the CPU.
|
// Add white background only to avatars to spare the CPU.
|
||||||
let mut add_white_bg = img_wh <= constants::BALANCED_AVATAR_SIZE;
|
let mut add_white_bg = is_avatar;
|
||||||
let mut no_exif = false;
|
let mut no_exif = false;
|
||||||
let no_exif_ref = &mut no_exif;
|
let no_exif_ref = &mut no_exif;
|
||||||
let res = tokio::task::block_in_place(move || {
|
let res = tokio::task::block_in_place(move || {
|
||||||
@@ -500,7 +491,7 @@ impl<'a> BlobObject<'a> {
|
|||||||
// also `Viewtype::Gif` (maybe renamed to `Animation`) should be used for animated
|
// also `Viewtype::Gif` (maybe renamed to `Animation`) should be used for animated
|
||||||
// images.
|
// images.
|
||||||
let do_scale = exceeds_max_bytes
|
let do_scale = exceeds_max_bytes
|
||||||
|| strict_limits
|
|| is_avatar
|
||||||
&& (exceeds_wh
|
&& (exceeds_wh
|
||||||
|| exif.is_some() && {
|
|| exif.is_some() && {
|
||||||
if mem::take(&mut add_white_bg) {
|
if mem::take(&mut add_white_bg) {
|
||||||
@@ -537,7 +528,7 @@ impl<'a> BlobObject<'a> {
|
|||||||
ofmt.clone(),
|
ofmt.clone(),
|
||||||
max_bytes,
|
max_bytes,
|
||||||
&mut encoded,
|
&mut encoded,
|
||||||
)? && strict_limits
|
)? && is_avatar
|
||||||
{
|
{
|
||||||
if img_wh < 20 {
|
if img_wh < 20 {
|
||||||
return Err(format_err!(
|
return Err(format_err!(
|
||||||
@@ -586,7 +577,7 @@ impl<'a> BlobObject<'a> {
|
|||||||
match res {
|
match res {
|
||||||
Ok(_) => res,
|
Ok(_) => res,
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
if !strict_limits && no_exif {
|
if !is_avatar && no_exif {
|
||||||
warn!(
|
warn!(
|
||||||
context,
|
context,
|
||||||
"Cannot recode image, using original data: {err:#}.",
|
"Cannot recode image, using original data: {err:#}.",
|
||||||
@@ -763,6 +754,8 @@ mod tests {
|
|||||||
use fs::File;
|
use fs::File;
|
||||||
|
|
||||||
use super::*;
|
use super::*;
|
||||||
|
use crate::config::Config;
|
||||||
|
use crate::constants::{self, MediaQuality};
|
||||||
use crate::message::{Message, Viewtype};
|
use crate::message::{Message, Viewtype};
|
||||||
use crate::test_utils::{self, TestContext};
|
use crate::test_utils::{self, TestContext};
|
||||||
|
|
||||||
@@ -999,14 +992,14 @@ mod tests {
|
|||||||
let mut blob = BlobObject::new_from_path(&t, &avatar_src).await.unwrap();
|
let mut blob = BlobObject::new_from_path(&t, &avatar_src).await.unwrap();
|
||||||
let img_wh = 128;
|
let img_wh = 128;
|
||||||
let maybe_sticker = &mut false;
|
let maybe_sticker = &mut false;
|
||||||
let strict_limits = true;
|
let is_avatar = true;
|
||||||
blob.recode_to_size(
|
blob.recode_to_size(
|
||||||
&t,
|
&t,
|
||||||
blob.to_abs_path(),
|
blob.to_abs_path(),
|
||||||
maybe_sticker,
|
maybe_sticker,
|
||||||
img_wh,
|
img_wh,
|
||||||
20_000,
|
20_000,
|
||||||
strict_limits,
|
is_avatar,
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
tokio::task::block_in_place(move || {
|
tokio::task::block_in_place(move || {
|
||||||
@@ -1030,16 +1023,18 @@ mod tests {
|
|||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert!(avatar_blob.exists());
|
assert!(avatar_blob.exists());
|
||||||
assert!(fs::metadata(&avatar_blob).await.unwrap().len() < avatar_bytes.len() as u64);
|
{
|
||||||
|
let avatar_blob = &avatar_blob;
|
||||||
|
tokio::task::block_in_place(move || {
|
||||||
|
let (_, exif) = image_metadata(&std::fs::File::open(avatar_blob).unwrap()).unwrap();
|
||||||
|
assert!(exif.is_none());
|
||||||
|
});
|
||||||
|
}
|
||||||
let avatar_cfg = t.get_config(Config::Selfavatar).await.unwrap();
|
let avatar_cfg = t.get_config(Config::Selfavatar).await.unwrap();
|
||||||
assert_eq!(avatar_cfg, avatar_blob.to_str().map(|s| s.to_string()));
|
assert_eq!(avatar_cfg, avatar_blob.to_str().map(|s| s.to_string()));
|
||||||
|
|
||||||
check_image_size(avatar_src, 1000, 1000);
|
check_image_size(avatar_src, 1000, 1000);
|
||||||
check_image_size(
|
check_image_size(&avatar_blob, 1000, 1000);
|
||||||
&avatar_blob,
|
|
||||||
constants::BALANCED_AVATAR_SIZE,
|
|
||||||
constants::BALANCED_AVATAR_SIZE,
|
|
||||||
);
|
|
||||||
|
|
||||||
async fn file_size(path_buf: &Path) -> u64 {
|
async fn file_size(path_buf: &Path) -> u64 {
|
||||||
let file = File::open(path_buf).await.unwrap();
|
let file = File::open(path_buf).await.unwrap();
|
||||||
@@ -1048,16 +1043,9 @@ mod tests {
|
|||||||
|
|
||||||
let mut blob = BlobObject::new_from_path(&t, &avatar_blob).await.unwrap();
|
let mut blob = BlobObject::new_from_path(&t, &avatar_blob).await.unwrap();
|
||||||
let maybe_sticker = &mut false;
|
let maybe_sticker = &mut false;
|
||||||
let strict_limits = true;
|
let is_avatar = true;
|
||||||
blob.recode_to_size(
|
blob.recode_to_size(&t, blob.to_abs_path(), maybe_sticker, 1000, 3000, is_avatar)
|
||||||
&t,
|
.unwrap();
|
||||||
blob.to_abs_path(),
|
|
||||||
maybe_sticker,
|
|
||||||
1000,
|
|
||||||
3000,
|
|
||||||
strict_limits,
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
assert!(file_size(&avatar_blob).await <= 3000);
|
assert!(file_size(&avatar_blob).await <= 3000);
|
||||||
assert!(file_size(&avatar_blob).await > 2000);
|
assert!(file_size(&avatar_blob).await > 2000);
|
||||||
tokio::task::block_in_place(move || {
|
tokio::task::block_in_place(move || {
|
||||||
@@ -1086,11 +1074,7 @@ mod tests {
|
|||||||
avatar_src.with_extension("png").to_str().unwrap()
|
avatar_src.with_extension("png").to_str().unwrap()
|
||||||
);
|
);
|
||||||
|
|
||||||
check_image_size(
|
check_image_size(avatar_cfg, 900, 900);
|
||||||
avatar_cfg,
|
|
||||||
constants::BALANCED_AVATAR_SIZE,
|
|
||||||
constants::BALANCED_AVATAR_SIZE,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||||
|
|||||||
@@ -4068,7 +4068,9 @@ pub async fn set_chat_profile_image(
|
|||||||
msg.text = stock_str::msg_grp_img_deleted(context, ContactId::SELF).await;
|
msg.text = stock_str::msg_grp_img_deleted(context, ContactId::SELF).await;
|
||||||
} else {
|
} else {
|
||||||
let mut image_blob = BlobObject::new_from_path(context, Path::new(new_image)).await?;
|
let mut image_blob = BlobObject::new_from_path(context, Path::new(new_image)).await?;
|
||||||
image_blob.recode_to_avatar_size(context).await?;
|
image_blob
|
||||||
|
.recode_to_avatar_size(context, chat.is_protected())
|
||||||
|
.await?;
|
||||||
chat.param.set(Param::ProfileImage, image_blob.as_name());
|
chat.param.set(Param::ProfileImage, image_blob.as_name());
|
||||||
msg.param.set(Param::Arg, image_blob.as_name());
|
msg.param.set(Param::Arg, image_blob.as_name());
|
||||||
msg.text = stock_str::msg_grp_img_changed(context, ContactId::SELF).await;
|
msg.text = stock_str::msg_grp_img_changed(context, ContactId::SELF).await;
|
||||||
|
|||||||
@@ -7,13 +7,14 @@ use std::str::FromStr;
|
|||||||
use anyhow::{ensure, Context as _, Result};
|
use anyhow::{ensure, Context as _, Result};
|
||||||
use base64::Engine as _;
|
use base64::Engine as _;
|
||||||
use deltachat_contact_tools::{addr_cmp, sanitize_single_line};
|
use deltachat_contact_tools::{addr_cmp, sanitize_single_line};
|
||||||
|
use num_traits::FromPrimitive;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use strum::{EnumProperty, IntoEnumIterator};
|
use strum::{EnumProperty, IntoEnumIterator};
|
||||||
use strum_macros::{AsRefStr, Display, EnumIter, EnumString};
|
use strum_macros::{AsRefStr, Display, EnumIter, EnumString};
|
||||||
use tokio::fs;
|
use tokio::fs;
|
||||||
|
|
||||||
use crate::blob::BlobObject;
|
use crate::blob::BlobObject;
|
||||||
use crate::constants;
|
use crate::constants::{self, MediaQuality};
|
||||||
use crate::context::Context;
|
use crate::context::Context;
|
||||||
use crate::events::EventType;
|
use crate::events::EventType;
|
||||||
use crate::log::LogExt;
|
use crate::log::LogExt;
|
||||||
@@ -664,6 +665,18 @@ impl Context {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn max_image_wh_and_size(&self) -> Result<(u32, usize)> {
|
||||||
|
match MediaQuality::from_i32(self.get_config_int(Config::MediaQuality).await?)
|
||||||
|
.unwrap_or_default()
|
||||||
|
{
|
||||||
|
MediaQuality::Balanced => Ok((
|
||||||
|
constants::BALANCED_IMAGE_SIZE,
|
||||||
|
constants::BALANCED_IMAGE_BYTES,
|
||||||
|
)),
|
||||||
|
MediaQuality::Worse => Ok((constants::WORSE_IMAGE_SIZE, constants::WORSE_IMAGE_BYTES)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Executes [`SyncData::Config`] item sent by other device.
|
/// Executes [`SyncData::Config`] item sent by other device.
|
||||||
pub(crate) async fn sync_config(&self, key: &Config, value: &str) -> Result<()> {
|
pub(crate) async fn sync_config(&self, key: &Config, value: &str) -> Result<()> {
|
||||||
let config_value;
|
let config_value;
|
||||||
@@ -749,7 +762,8 @@ impl Context {
|
|||||||
match value {
|
match value {
|
||||||
Some(path) => {
|
Some(path) => {
|
||||||
let mut blob = BlobObject::new_from_path(self, path.as_ref()).await?;
|
let mut blob = BlobObject::new_from_path(self, path.as_ref()).await?;
|
||||||
blob.recode_to_avatar_size(self).await?;
|
let protected = true;
|
||||||
|
blob.recode_to_avatar_size(self, protected).await?;
|
||||||
self.sql
|
self.sql
|
||||||
.set_raw_config(key.as_ref(), Some(blob.as_name()))
|
.set_raw_config(key.as_ref(), Some(blob.as_name()))
|
||||||
.await?;
|
.await?;
|
||||||
|
|||||||
@@ -204,10 +204,6 @@ pub(crate) const DC_FETCH_EXISTING_MSGS_COUNT: i64 = 100;
|
|||||||
pub const BALANCED_IMAGE_BYTES: usize = 500_000;
|
pub const BALANCED_IMAGE_BYTES: usize = 500_000;
|
||||||
pub const WORSE_IMAGE_BYTES: usize = 130_000;
|
pub const WORSE_IMAGE_BYTES: usize = 130_000;
|
||||||
|
|
||||||
// max. width/height of an avatar
|
|
||||||
pub(crate) const BALANCED_AVATAR_SIZE: u32 = 256;
|
|
||||||
pub(crate) const WORSE_AVATAR_SIZE: u32 = 128;
|
|
||||||
|
|
||||||
// max. width/height of images scaled down because of being too huge
|
// max. width/height of images scaled down because of being too huge
|
||||||
pub const BALANCED_IMAGE_SIZE: u32 = 1280;
|
pub const BALANCED_IMAGE_SIZE: u32 = 1280;
|
||||||
pub const WORSE_IMAGE_SIZE: u32 = 640;
|
pub const WORSE_IMAGE_SIZE: u32 = 640;
|
||||||
|
|||||||
@@ -258,7 +258,8 @@ impl Sql {
|
|||||||
if recode_avatar {
|
if recode_avatar {
|
||||||
if let Some(avatar) = context.get_config(Config::Selfavatar).await? {
|
if let Some(avatar) = context.get_config(Config::Selfavatar).await? {
|
||||||
let mut blob = BlobObject::new_from_path(context, avatar.as_ref()).await?;
|
let mut blob = BlobObject::new_from_path(context, avatar.as_ref()).await?;
|
||||||
match blob.recode_to_avatar_size(context).await {
|
let protected = true;
|
||||||
|
match blob.recode_to_avatar_size(context, protected).await {
|
||||||
Ok(()) => {
|
Ok(()) => {
|
||||||
context
|
context
|
||||||
.set_config_internal(Config::Selfavatar, Some(&avatar))
|
.set_config_internal(Config::Selfavatar, Some(&avatar))
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use pretty_assertions::assert_eq;
|
use pretty_assertions::assert_eq;
|
||||||
|
|
||||||
|
use crate::blob;
|
||||||
use crate::chat::{self, add_contact_to_chat, Chat, ProtectionStatus};
|
use crate::chat::{self, add_contact_to_chat, Chat, ProtectionStatus};
|
||||||
use crate::chatlist::Chatlist;
|
use crate::chatlist::Chatlist;
|
||||||
use crate::config::Config;
|
use crate::config::Config;
|
||||||
@@ -953,6 +954,37 @@ async fn test_no_unencrypted_name_if_encrypted() -> Result<()> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||||
|
async fn test_contact_avatar() -> Result<()> {
|
||||||
|
let mut tcm = TestContextManager::new();
|
||||||
|
let alice = &tcm.alice().await;
|
||||||
|
enable_verified_oneonone_chats(&[alice]).await;
|
||||||
|
let bob = &tcm.bob().await;
|
||||||
|
mark_as_verified(alice, bob).await;
|
||||||
|
let alice_bob_chat = alice.create_chat(bob).await;
|
||||||
|
let file = alice.dir.path().join("avatar.jpg");
|
||||||
|
let bytes = include_bytes!("../../test-data/image/avatar1000x1000.jpg");
|
||||||
|
tokio::fs::write(&file, bytes).await?;
|
||||||
|
alice
|
||||||
|
.set_config(Config::Selfavatar, Some(file.to_str().unwrap()))
|
||||||
|
.await?;
|
||||||
|
let sent_msg = alice
|
||||||
|
.send_text(alice_bob_chat.id, "hello, I have a new avatar")
|
||||||
|
.await;
|
||||||
|
bob.recv_msg(&sent_msg).await;
|
||||||
|
let bob_alice_contact = bob.add_or_lookup_contact(alice).await;
|
||||||
|
let avatar_path = bob_alice_contact.get_profile_image(bob).await?.unwrap();
|
||||||
|
tokio::task::block_in_place(move || {
|
||||||
|
let (_, exif) = blob::image_metadata(&std::fs::File::open(&avatar_path)?)?;
|
||||||
|
assert!(exif.is_none());
|
||||||
|
let img = image::open(&avatar_path)?;
|
||||||
|
assert_eq!(img.width(), 1000);
|
||||||
|
assert_eq!(img.height(), 1000);
|
||||||
|
Result::<()>::Ok(())
|
||||||
|
})?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
// ============== Helper Functions ==============
|
// ============== Helper Functions ==============
|
||||||
|
|
||||||
async fn assert_verified(this: &TestContext, other: &TestContext, protected: ProtectionStatus) {
|
async fn assert_verified(this: &TestContext, other: &TestContext, protected: ProtectionStatus) {
|
||||||
|
|||||||
Reference in New Issue
Block a user