-X.loClip?0:i+X.loWoBoffset),n<0?a*100:n==0?Math.round(Math.abs(a)*100)+``+o+``:Number.isInteger(n)?(a*100).toFixed(n):0)}function bi(e=[0,0,0]){function t(e){return(e/255)**X.mainTRC}return X.sRco*t(e[0])+X.sGco*t(e[1])+X.sBco*t(e[2])}var xi=(e,t,n,r,i,a,o,s,c)=>{let l=1-c,u=l*l,d=u*l,f=c*c*c;return{x:d*e+u*3*c*n+l*3*c*c*i+f*o,y:d*t+u*3*c*r+l*3*c*c*a+f*s}},Si=(e,t)=>{let n=[],r={x:+e[0],y:+e[1]};for(let i=0,a=e.length;a-2*!t>i;i+=2){let o=[{x:+e[i-2],y:+e[i-1]},{x:+e[i],y:+e[i+1]},{x:+e[i+2],y:+e[i+3]},{x:+e[i+4],y:+e[i+5]}];t?i?a-4===i?o[3]={x:+e[0],y:+e[1]}:a-2===i&&(o[2]={x:+e[0],y:+e[1]},o[3]={x:+e[2],y:+e[3]}):o[0]={x:+e[a-2],y:+e[a-1]}:a-4===i?o[3]=o[2]:i||(o[0]={x:+e[i],y:+e[i+1]}),n.push([r.x,r.y,(-o[0].x+6*o[1].x+o[2].x)/6,(-o[0].y+6*o[1].y+o[2].y)/6,(o[1].x+6*o[2].x-o[3].x)/6,(o[1].y+6*o[2].y-o[3].y)/6,o[2].x,o[2].y]),r=o[2]}return n},Ci=(e,t,n,r,i,a,o,s)=>{let c=e,l=t,u=0;for(let d=1;d<5;d++){let{x:f,y:p}=xi(e,t,n,r,i,a,o,s,d/5);u+=Math.hypot(f-c,p-l),c=f,l=p}return u+=Math.hypot(o-c,s-l),u},wi=(e,t,n,r,i,a,o,s)=>{let c=Math.floor(Ci(e,t,n,r,i,a,o,s)*.75),l=[],u=0;for(let d=0;d<=c;d++){let f=xi(e,t,n,r,i,a,o,s,d/c),p=Math.round(f.x);if(l[p]=f.y,p-u>1){let e=l[u],t=l[p];for(let n=u+1;nl[Math.round(e)]||null},Ti={CAM02:`jab`,CAM02p:`jch`,HEX:`hex`,HSL:`hsl`,HSLuv:`hsluv`,HSV:`hsv`,LAB:`lab`,LCH:`lch`,RGB:`rgb`,OKLAB:`oklab`,OKLCH:`oklch`};function Z(e,t=0){let n=10**t;return Math.round(e*n)/n}function Ei(e,t){let n;return n=e>1?(e-1)*t+1:e<-1?(e+1)*t-1:1,Z(n,2)}function Di(e){return K(String(e)).jch()}function Oi(e){return K(String(e)).hsluv()}function ki(e,t,n){let r=[[],[],[]];if(e.forEach((e,n)=>r.forEach((r,i)=>r.push(t[n],e[i]))),n===`hcl`){let e=r[1];for(let t=1;t{let t=[];for(let n=1;n{e[t]=e[n]}),t.length=0;break}if(t.length){let n=K(`#ccc`).jch()[2];t.forEach(t=>{e[t]=n})}t.length=0;for(let n=e.length-1;n>0;n-=2)if(Number.isNaN(e[n]))t.push(n);else{t.forEach(t=>{e[t]=e[n]});break}for(let t=1;tSi(e).map(e=>wi(...e)));return e=>{let t=i.map(t=>{for(let n=0;nr*t**e+i}function ji({swatches:e,colorKeys:t,colorspace:n,colorSpace:r=n??`LAB`,shift:i=1,fullScale:a=!0,smooth:o=!1,distributeLightness:s=`linear`,sortColor:c=!0,asFun:l=!1}={}){n!==void 0&&console.warn("Leonardo: `colorspace` is deprecated. Use `colorSpace` instead.");let u=Ti[r];if(!u)throw Error(`Colorspace “${r}” not supported`);if(!t)throw Error(`Colorkeys missing: returned “${t}”`);let d;if(a)d=t.map(t=>e-e*(K(t).jch()[0]/100)).sort((e,t)=>e-t).concat(e),d.unshift(0);else{let n=t.map(e=>K(e).jch()[0]/100),r=Math.min(...n),i=Math.max(...n);d=n.map(t=>t===0||isNaN((t-r)/(i-r))?0:e-(t-r)/(i-r)*e).sort((e,t)=>e-t)}let f=Ai(i,[1,e],[1,e]);if(f=d.map(e=>Math.max(0,f(e))),d=f,s===`polynomial`){let t=e=>Math.sqrt(Math.sqrt((e**2.25+e**4)/2));d=f.map(t=>t/e).map(n=>t(n)*e)}let p=t.map((e,t)=>({colorKeys:Di(e),index:t})).sort((e,t)=>t.colorKeys[0]-e.colorKeys[0]).map(e=>t[e.index]),m=[],h;if(a){let e=u===`lch`?K.lch(...K(`#fff`).lch()):`#ffffff`,t=u===`lch`?K.lch(...K(`#000`).lch()):`#000000`;m=[e,...p,t]}else m=c?p:t;let g;if(o){let t=m;if(m=m.map(e=>K(String(e))[u]()),u===`hcl`&&m.forEach(e=>{e[1]=Number.isNaN(e[1])?0:e[1]}),u===`jch`)for(let e=0;eh(t))}else h=K.scale(m.map(e=>typeof e==`object`&&e.constructor===K.Color?e:String(e))).domain(d).mode(u);return l?h:(!o||o===!1?h.colors(e):g).filter(e=>e!=null)}function Mi(e,t){let n=[],r={};return Object.keys(e).forEach(n=>{r[e[n][t]]=e[n]}),Object.keys(r).forEach(e=>n.push(r[e])),n}function Ni(e){return Number.isNaN(e)?0:e}function Pi(e,t,n=!1){if(!e)throw Error(`Cannot convert color value of “${e}”`);if(!Ti[t])throw Error(`Cannot convert to colorspace “${t}”`);let r=Ti[t],i=K(String(e))[r]();if(t===`HSL`&&i.pop(),t===`HEX`){if(n){let t=K(String(e)).rgb();return{r:t[0],g:t[1],b:t[2]}}return i}let a={},o=i.map(Ni);o=o.map((e,t)=>{let i=Z(e),o=t;r===`hsluv`&&(o+=2);let s=r.charAt(o);return r===`jch`&&s===`c`&&(s=`C`),a[s===`j`?`J`:s]=i,r in{lab:1,lch:1,jab:1,jch:1}?n||(s===`l`||s===`j`)&&(i+=`%`):r!==`hsluv`&&(s===`s`||s===`l`||s===`v`)&&(a[s]=Z(e,2),n||(i=Z(e*100),i+=`%`)),i});let s=`${r}(${o.join(`, `)})`;return n?a:s}function Fi(e,t,n){let r=[e,t,n].map(e=>(e/=255,e<=.03928?e/12.92:((e+.055)/1.055)**2.4));return r[0]*.2126+r[1]*.7152+r[2]*.0722}function Ii(e,t,n,r=`wcag2`){if(n===void 0){let e=K.rgb(...t).hsluv()[2];n=Z(e/100,2)}if(r===`wcag2`){let r=Fi(e[0],e[1],e[2]),i=Fi(t[0],t[1],t[2]),a=(r+.05)/(i+.05),o=(i+.05)/(r+.05);return n<.5?a>=1?a:-o:a<1?o:a===1?a:-a}else if(r===`wcag3`)return n<.5?yi(bi(e),bi(t))*-1:yi(bi(e),bi(t));else throw Error(`Contrast calculation method ${r} unsupported; use 'wcag2' or 'wcag3'`)}function Li(e,t){if(!e)throw Error(`Array undefined`);if(!Array.isArray(e))throw Error(`Passed object is not an array`);let n=t===`wcag2`?0:1;return Math.min(...e.filter(e=>e>=n))}function Ri(e,t){if(!e)throw Error(`Ratios undefined`);e=e.sort((e,t)=>e-t);let n=Li(e,t),r=e.indexOf(n),i=[],a=e.slice(0,r),o=e.slice(r,e.length);for(let e=0;ee-t),i}var zi=(e,t,n,r,i)=>{let a=3e3,o=ji({swatches:a,colorKeys:e._modifiedKeys,colorspace:e._colorspace,shift:1,smooth:e._smooth,asFun:!0}),s={},c=e=>{if(s[e])return s[e];let r=Ii(K(o(e)).rgb(),t,n,i);return s[e]=r,r},l=e=>{let t=c(0).01&&o;)o--,n/=2,iu.push(o(l(+e)))),u},Q=class{constructor({name:e,colorKeys:t,colorspace:n,colorSpace:r=n??`RGB`,ratios:i,smooth:a=!1,output:o=`HEX`,saturation:s=100}){if(n!==void 0&&console.warn("Leonardo: `colorspace` is deprecated. Use `colorSpace` instead."),this._name=e,this._colorKeys=t,this._modifiedKeys=t,this._colorspace=r,this._ratios=i,this._smooth=a,this._output=o,this._saturation=s,!this._name)throw Error(`Color missing name`);if(!this._colorKeys)throw Error(`Color Keys are undefined`);if(!Ti[this._colorspace])throw Error(`Colorspace “${r}” not supported`);if(!Ti[this._output])throw Error(`Output “${this._output}” not supported`);for(let e=0;e{let n=K(`${t}`).oklch(),r=n[1]*(this._saturation/100),i=K.oklch(n[0],r,n[2]),a=K.rgb(i).hex();e.push(a)}),this._modifiedKeys=e,this._generateColorScale()}_generateColorScale(){this._colorScale=ji({swatches:3e3,colorKeys:this._modifiedKeys,colorSpace:this._colorspace,shift:1,smooth:this._smooth,asFun:!0})}},Bi=class extends Q{get backgroundColorScale(){return this._backgroundColorScale||this._generateColorScale(),this._backgroundColorScale}_generateColorScale(){Q.prototype._generateColorScale.call(this);let e=ji({swatches:1e3,colorKeys:this._colorKeys,colorspace:this._colorspace,shift:1,smooth:this._smooth});e.push(...this.colorKeys);let t=Mi(e.map((e,t)=>({value:Math.round(Oi(e)[2]),index:t})),`value`).map(t=>e[t.index]);return t.length>=101&&(t.length=100,t.push(`#ffffff`)),this._backgroundColorScale=t.map(e=>Pi(e,this._output)),this._backgroundColorScale}},Vi=class{constructor({colors:e,backgroundColor:t,lightness:n,contrast:r=1,saturation:i=100,output:a=`HEX`,formula:o=`wcag2`}){if(this._output=a,this._colors=e,this._lightness=n,this._saturation=i,this._formula=o,this._setBackgroundColor(t),this._setBackgroundColorValue(),this._contrast=r,!this._colors)throw Error(`No colors are defined`);if(!this._backgroundColor)throw Error(`Background color is undefined`);if(e.forEach(e=>{if(!e.ratios)throw Error(`Color ${e.name}'s ratios are undefined`)}),!Ti[this._output])throw Error(`Output “${a}” not supported`);this._saturation<100&&this._updateColorSaturation(this._saturation),this._findContrastColors(),this._findContrastColorPairs(),this._findContrastColorValues()}set formula(e){this._formula=e,this._findContrastColors()}get formula(){return this._formula}set contrast(e){this._contrast=e,this._findContrastColors()}get contrast(){return this._contrast}set lightness(e){this._lightness=e,this._setBackgroundColor(this._backgroundColor),this._findContrastColors()}get lightness(){return this._lightness}set saturation(e){this._saturation=e,this._updateColorSaturation(e),this._findContrastColors()}get saturation(){return this._saturation}set backgroundColor(e){this._setBackgroundColor(e),this._findContrastColors()}get backgroundColorValue(){return this._backgroundColorValue}get backgroundColor(){return this._backgroundColor}set colors(e){this._colors=e,this._findContrastColors()}get colors(){return this._colors}set addColor(e){this._colors.push(e),this._findContrastColors()}set removeColor(e){let t=this._colors.filter(t=>t.name!==e.name);this._colors=t,this._findContrastColors()}set updateColor(e){if(Array.isArray(e))for(let t=0;tn.name===e[t].color);n=n[0];let r=this._colors.indexOf(n),i=this._colors.filter(n=>n.name!==e[t].color);e[t].name&&(n.name=e[t].name),e[t].colorKeys&&(n.colorKeys=e[t].colorKeys),e[t].ratios&&(n.ratios=e[t].ratios),(e[t].colorSpace!==void 0||e[t].colorspace!==void 0)&&(e[t].colorspace!==void 0&&e[t].colorSpace===void 0&&console.warn("Leonardo: `colorspace` is deprecated. Use `colorSpace` instead."),n.colorSpace=e[t].colorSpace??e[t].colorspace),e[t].smooth&&(n.smooth=e[t].smooth),n._generateColorScale(),i.splice(r,0,n),this._colors=i}else{let t=this._colors.filter(t=>t.name===e.color);t=t[0];let n=this._colors.indexOf(t),r=this._colors.filter(t=>t.name!==e.color);e.name&&(t.name=e.name),e.colorKeys&&(t.colorKeys=e.colorKeys),e.ratios&&(t.ratios=e.ratios),(e.colorSpace!==void 0||e.colorspace!==void 0)&&(e.colorspace!==void 0&&e.colorSpace===void 0&&console.warn("Leonardo: `colorspace` is deprecated. Use `colorSpace` instead."),t.colorSpace=e.colorSpace??e.colorspace),e.smooth&&(t.smooth=e.smooth),t._generateColorScale(),r.splice(n,0,t),this._colors=r}this._findContrastColors()}set output(e){this._output=e,this._colors.forEach(e=>{e.output=this._output}),this._backgroundColor.output=this._output,this._findContrastColors()}get output(){return this._output}get contrastColors(){return this._contrastColors}get contrastColorPairs(){return this._contrastColorPairs}get contrastColorValues(){return this._contrastColorValues}_setBackgroundColor(e){if(typeof e==`string`){let t=new Bi({name:`background`,colorKeys:[e],output:`RGB`}),n=Z(K(String(e)).hsluv()[2]);this._backgroundColor=t,this._lightness=n,this._backgroundColorValue=t[this._lightness]}else{e.output=`RGB`;let t=e.backgroundColorScale[this._lightness];this._backgroundColor=e,this._backgroundColorValue=t}}_setBackgroundColorValue(){this._backgroundColorValue=this._backgroundColor.backgroundColorScale[this._lightness]}_updateColorSaturation(e){this._colors.map(t=>{t.saturation=e})}_findContrastColors(){let e=K(String(this._backgroundColorValue)).rgb(),t=this._lightness/100,n={background:Pi(this._backgroundColorValue,this._output)},r=[],i=[],a={...n};return r.push(n),this._colors.map(n=>{if(n.ratios!==void 0){let o,s=[],c={name:n.name,values:s},l;Array.isArray(n.ratios)?l=n.ratios:Array.isArray(n.ratios)||(o=Object.keys(n.ratios),l=Object.values(n.ratios)),l=l.map(e=>Ei(+e,this._contrast));let u=zi(n,e,t,l,this._formula).map(e=>Pi(e,this._output));for(let e=0;e{let t=/^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(e);return[Number.parseInt(t[1],16),Number.parseInt(t[2],16),Number.parseInt(t[3],16)]},Wi=(e,t,n)=>{let r=e/255,i=t/255,a=n/255,o=Math.min(r,i,a),s=Math.max(r,i,a),c=s-o,l=0,u=0,d=0;return l=c===0?0:s===r?(i-a)/c%6:s===i?(a-r)/c+2:(r-i)/c+4,l=Math.round(l*60),l<0&&(l+=360),d=(s+o)/2,u=c===0?0:c/(1-Math.abs(2*d-1)),u=+(u*100).toFixed(1),d=+(d*100).toFixed(1),[l,u,Math.round(d)]},Gi=(e,t,n,r)=>{let i=n/100,a=t*Math.min(i,1-i)/100,o=t=>{let n=(t+e/30)%12,r=i-a*Math.max(Math.min(n-3,9-n,1),-1);return Math.round(255*r).toString(16).padStart(2,`0`).toUpperCase()},s=o(0),c=o(8),l=o(4),u=((e,t,n)=>Math.min(Math.max(e,t),n))(r,0,1);return`#${s}${c}${l}${Math.round(u*255).toString(16).padStart(2,`0`).toUpperCase()}`},Ki=(e,t,n=1)=>{let r=Ui(e),i=Ui(t===`white`?`#FFFFFF`:t===`black`?`#000000`:t),a=r.map((e,t)=>[(e-i[t])/(255-i[t]),(e-i[t])/(0-i[t])]),o=Hi(Math.max(...a.flat().filter(e=>/^-?\d+\.?\d*$/.test(e)))),s=r.map((e,t)=>Math.round((e-i[t]+i[t]*o)/o));if(s.includes(NaN)){let e=Wi(r[0],r[1],r[2]);return{h:e[0],s:Math.round(e[1]*n),l:e[2],a:1}}let c=Wi(s[0],s[1],s[2]);return{h:c[0],s:Math.round(c[1]*n),l:c[2],a:o}},qi={backgroundColor:`gray`,colorSpace:`OKLCH`,colorSmoothing:!1,formula:`wcag2`,output:`HEX`,colors:{gray:[$(215,20,90),$(215,8,50),$(215,6,25)],red:[$(358,100,58),$(350,100,30)],orange:[$(32,100,48),$(12,100,30)],yellow:[$(50,100,50),$(25,100,20)],lime:[$(100,68,50),$(115,86,25)],green:[$(163,87,42),$(168,100,25)],cyan:[$(185,80,45),$(200,98,35)],blue:[$(212,98,46),$(222,95,25)],purple:[$(258,94,64),$(265,100,35)],fuchsia:[$(295,56,50),$(285,80,25)],pink:[$(334,90,50),$(330,91,25)]},themes:{light:{ratios:[1.03,1.06,1.12,1.25,1.5,1.75,2.25,3.5,5.25,6.5,8,10.5,13.75,16.75],contrast:1,lightness:100,saturation:100},dark:{ratios:[1.03,1.06,1.12,1.25,1.5,1.75,2.25,3.5,5.25,6.5,8,10.5,13.75,16],contrast:1,lightness:6,saturation:97},lightHc:{ratios:[1.06,1.12,1.25,1.37,1.75,2.25,3.25,4.75,8.87,10,11.75,13.25,16,17],contrast:1,lightness:100,saturation:100},darkHc:{ratios:[1.06,1.12,1.25,1.37,1.75,2.25,3.25,4.75,8.87,10,11.75,13.25,16,17],contrast:1,lightness:6,saturation:97}}};function $(e,t,n){return K.hsl(e,t/100,n/100).hex()}function Ji(e,t){let n=e.colorSpace,r=e.colorSmoothing,i=e.themes[t].ratios,a=new Bi({name:`gray`,colorKeys:e.colors.gray,colorspace:n,ratios:i,smooth:r}),o=new Q({name:`blue`,colorKeys:e.colors.blue,colorspace:n,ratios:i,smooth:r}),s=new Q({name:`cyan`,colorKeys:e.colors.cyan,colorspace:n,ratios:i,smooth:r}),c=new Q({name:`fuchsia`,colorKeys:e.colors.fuchsia,colorspace:n,ratios:i,smooth:r}),l=new Q({name:`green`,colorKeys:e.colors.green,colorspace:n,ratios:i,smooth:r}),u=new Q({name:`lime`,colorKeys:e.colors.lime,colorspace:n,ratios:i,smooth:r}),d=new Q({name:`orange`,colorKeys:e.colors.orange,colorspace:n,ratios:i,smooth:r}),f=new Q({name:`pink`,colorKeys:e.colors.pink,colorspace:n,ratios:i,smooth:r}),p=new Q({name:`purple`,colorKeys:e.colors.purple,colorspace:n,ratios:i,smooth:r}),m={gray:a,red:new Q({name:`red`,colorKeys:e.colors.red,colorspace:n,ratios:i,smooth:r}),orange:d,yellow:new Q({name:`yellow`,colorKeys:e.colors.yellow,colorspace:n,ratios:i,smooth:r}),lime:u,green:l,cyan:s,blue:o,purple:p,fuchsia:c,pink:f};return e.colors.custom&&(m.custom=new Q({name:`custom`,colorKeys:e.colors.custom,colorspace:n,ratios:i,smooth:r})),new Vi({colors:Object.values(m),backgroundColor:m[e.backgroundColor],contrast:e.themes[t].contrast,lightness:e.themes[t].lightness,saturation:e.themes[t].saturation,output:e.output,formula:e.formula}).contrastColors}function Yi(e){let t={};for(let n of Object.keys(e.themes))t[n]=Ji(e,n);return t}function Xi(e){qi.colors.custom=[e];let t=Yi(qi);return Object.fromEntries(Object.entries(t).map(([e,t])=>{let n=t.find(e=>e&&e.name===`custom`),r=Object.fromEntries(n.values.map(({name:e,value:t})=>[e,t]));for(let[e,n]of Object.entries(r)){let i=Ki(n,t[0].background);r[`alpha${e.charAt(0).toUpperCase()+e.slice(1)}`]=Gi(i.h,i.s,i.l,i.a)}return[e,r]}))}return e.generateCustomColors=Xi,e.generateThemesJson=Yi,e.hslToHex=$,e.leonardoConfig=qi,e})({});
\ No newline at end of file
diff --git a/libraries/compound/src/main/kotlin/io/element/android/compound/tokens/generated/CompoundIcons.kt b/libraries/compound/src/main/kotlin/io/element/android/compound/tokens/generated/CompoundIcons.kt
index 5851a758f0..d8f81dbbbb 100644
--- a/libraries/compound/src/main/kotlin/io/element/android/compound/tokens/generated/CompoundIcons.kt
+++ b/libraries/compound/src/main/kotlin/io/element/android/compound/tokens/generated/CompoundIcons.kt
@@ -130,6 +130,9 @@ object CompoundIcons {
@Composable fun Collapse(): ImageVector {
return ImageVector.vectorResource(R.drawable.ic_compound_collapse)
}
+ @Composable fun CollapseAll(): ImageVector {
+ return ImageVector.vectorResource(R.drawable.ic_compound_collapse_all)
+ }
@Composable fun Company(): ImageVector {
return ImageVector.vectorResource(R.drawable.ic_compound_company)
}
@@ -142,6 +145,9 @@ object CompoundIcons {
@Composable fun Copy(): ImageVector {
return ImageVector.vectorResource(R.drawable.ic_compound_copy)
}
+ @Composable fun Crop(): ImageVector {
+ return ImageVector.vectorResource(R.drawable.ic_compound_crop)
+ }
@Composable fun DarkMode(): ImageVector {
return ImageVector.vectorResource(R.drawable.ic_compound_dark_mode)
}
@@ -202,6 +208,9 @@ object CompoundIcons {
@Composable fun Expand(): ImageVector {
return ImageVector.vectorResource(R.drawable.ic_compound_expand)
}
+ @Composable fun ExpandAll(): ImageVector {
+ return ImageVector.vectorResource(R.drawable.ic_compound_expand_all)
+ }
@Composable fun Explore(): ImageVector {
return ImageVector.vectorResource(R.drawable.ic_compound_explore)
}
@@ -229,6 +238,15 @@ object CompoundIcons {
@Composable fun Filter(): ImageVector {
return ImageVector.vectorResource(R.drawable.ic_compound_filter)
}
+ @Composable fun FlipHorizontal(): ImageVector {
+ return ImageVector.vectorResource(R.drawable.ic_compound_flip_horizontal)
+ }
+ @Composable fun FlipVertical(): ImageVector {
+ return ImageVector.vectorResource(R.drawable.ic_compound_flip_vertical)
+ }
+ @Composable fun Folder(): ImageVector {
+ return ImageVector.vectorResource(R.drawable.ic_compound_folder)
+ }
@Composable fun Forward(): ImageVector {
return ImageVector.vectorResource(R.drawable.ic_compound_forward)
}
@@ -496,6 +514,12 @@ object CompoundIcons {
@Composable fun RotateRight(): ImageVector {
return ImageVector.vectorResource(R.drawable.ic_compound_rotate_right)
}
+ @Composable fun Save(): ImageVector {
+ return ImageVector.vectorResource(R.drawable.ic_compound_save)
+ }
+ @Composable fun SaveSolid(): ImageVector {
+ return ImageVector.vectorResource(R.drawable.ic_compound_save_solid)
+ }
@Composable fun Search(): ImageVector {
return ImageVector.vectorResource(R.drawable.ic_compound_search)
}
@@ -607,6 +631,9 @@ object CompoundIcons {
@Composable fun Unpin(): ImageVector {
return ImageVector.vectorResource(R.drawable.ic_compound_unpin)
}
+ @Composable fun Unsave(): ImageVector {
+ return ImageVector.vectorResource(R.drawable.ic_compound_unsave)
+ }
@Composable fun User(): ImageVector {
return ImageVector.vectorResource(R.drawable.ic_compound_user)
}
@@ -735,10 +762,12 @@ object CompoundIcons {
CloudSolid(),
Code(),
Collapse(),
+ CollapseAll(),
Company(),
Compose(),
Computer(),
Copy(),
+ Crop(),
DarkMode(),
Delete(),
DevicePasskey(),
@@ -759,6 +788,7 @@ object CompoundIcons {
ErrorSolid(),
ExitFullScreen(),
Expand(),
+ ExpandAll(),
Explore(),
ExportArchive(),
Extensions(),
@@ -768,6 +798,9 @@ object CompoundIcons {
FileError(),
Files(),
Filter(),
+ FlipHorizontal(),
+ FlipVertical(),
+ Folder(),
Forward(),
FullScreen(),
Grid(),
@@ -857,6 +890,8 @@ object CompoundIcons {
Room(),
RotateLeft(),
RotateRight(),
+ Save(),
+ SaveSolid(),
Search(),
Section(),
Send(),
@@ -894,6 +929,7 @@ object CompoundIcons {
Unknown(),
UnknownSolid(),
Unpin(),
+ Unsave(),
User(),
UserAdd(),
UserAddSolid(),
@@ -963,10 +999,12 @@ object CompoundIcons {
R.drawable.ic_compound_cloud_solid,
R.drawable.ic_compound_code,
R.drawable.ic_compound_collapse,
+ R.drawable.ic_compound_collapse_all,
R.drawable.ic_compound_company,
R.drawable.ic_compound_compose,
R.drawable.ic_compound_computer,
R.drawable.ic_compound_copy,
+ R.drawable.ic_compound_crop,
R.drawable.ic_compound_dark_mode,
R.drawable.ic_compound_delete,
R.drawable.ic_compound_device_passkey,
@@ -987,6 +1025,7 @@ object CompoundIcons {
R.drawable.ic_compound_error_solid,
R.drawable.ic_compound_exit_full_screen,
R.drawable.ic_compound_expand,
+ R.drawable.ic_compound_expand_all,
R.drawable.ic_compound_explore,
R.drawable.ic_compound_export_archive,
R.drawable.ic_compound_extensions,
@@ -996,6 +1035,9 @@ object CompoundIcons {
R.drawable.ic_compound_file_error,
R.drawable.ic_compound_files,
R.drawable.ic_compound_filter,
+ R.drawable.ic_compound_flip_horizontal,
+ R.drawable.ic_compound_flip_vertical,
+ R.drawable.ic_compound_folder,
R.drawable.ic_compound_forward,
R.drawable.ic_compound_full_screen,
R.drawable.ic_compound_grid,
@@ -1085,6 +1127,8 @@ object CompoundIcons {
R.drawable.ic_compound_room,
R.drawable.ic_compound_rotate_left,
R.drawable.ic_compound_rotate_right,
+ R.drawable.ic_compound_save,
+ R.drawable.ic_compound_save_solid,
R.drawable.ic_compound_search,
R.drawable.ic_compound_section,
R.drawable.ic_compound_send,
@@ -1122,6 +1166,7 @@ object CompoundIcons {
R.drawable.ic_compound_unknown,
R.drawable.ic_compound_unknown_solid,
R.drawable.ic_compound_unpin,
+ R.drawable.ic_compound_unsave,
R.drawable.ic_compound_user,
R.drawable.ic_compound_user_add,
R.drawable.ic_compound_user_add_solid,
diff --git a/libraries/compound/src/main/kotlin/io/element/android/compound/tokens/generated/SemanticColors.kt b/libraries/compound/src/main/kotlin/io/element/android/compound/tokens/generated/SemanticColors.kt
index f5a54bb7be..6bc6c89a63 100644
--- a/libraries/compound/src/main/kotlin/io/element/android/compound/tokens/generated/SemanticColors.kt
+++ b/libraries/compound/src/main/kotlin/io/element/android/compound/tokens/generated/SemanticColors.kt
@@ -175,6 +175,8 @@ data class SemanticColors(
val iconTertiaryAlpha: Color,
/** Used to separate core sections of the UI as well as containers */
val separatorPrimary: Color,
+ /** Secondary shade for separating sections of components or list items */
+ val separatorSecondary: Color,
/** Accent text colour for plain actions. */
val textActionAccent: Color,
/** Default text colour for plain actions. */
diff --git a/libraries/compound/src/main/kotlin/io/element/android/compound/tokens/generated/SemanticColorsDark.kt b/libraries/compound/src/main/kotlin/io/element/android/compound/tokens/generated/SemanticColorsDark.kt
index 35a1234854..e9334de0e7 100644
--- a/libraries/compound/src/main/kotlin/io/element/android/compound/tokens/generated/SemanticColorsDark.kt
+++ b/libraries/compound/src/main/kotlin/io/element/android/compound/tokens/generated/SemanticColorsDark.kt
@@ -103,6 +103,7 @@ val compoundColorsDark = SemanticColors(
iconTertiary = DarkColorTokens.colorGray800,
iconTertiaryAlpha = DarkColorTokens.colorAlphaGray800,
separatorPrimary = DarkColorTokens.colorGray400,
+ separatorSecondary = DarkColorTokens.colorGray300,
textActionAccent = DarkColorTokens.colorGreen900,
textActionPrimary = DarkColorTokens.colorGray1400,
textBadgeAccent = DarkColorTokens.colorGreen1100,
diff --git a/libraries/compound/src/main/kotlin/io/element/android/compound/tokens/generated/SemanticColorsDarkHc.kt b/libraries/compound/src/main/kotlin/io/element/android/compound/tokens/generated/SemanticColorsDarkHc.kt
index 802e3d56b0..a3ef51cff1 100644
--- a/libraries/compound/src/main/kotlin/io/element/android/compound/tokens/generated/SemanticColorsDarkHc.kt
+++ b/libraries/compound/src/main/kotlin/io/element/android/compound/tokens/generated/SemanticColorsDarkHc.kt
@@ -103,6 +103,7 @@ val compoundColorsHcDark = SemanticColors(
iconTertiary = DarkHcColorTokens.colorGray800,
iconTertiaryAlpha = DarkHcColorTokens.colorAlphaGray800,
separatorPrimary = DarkHcColorTokens.colorGray400,
+ separatorSecondary = DarkHcColorTokens.colorGray300,
textActionAccent = DarkHcColorTokens.colorGreen900,
textActionPrimary = DarkHcColorTokens.colorGray1400,
textBadgeAccent = DarkHcColorTokens.colorGreen1100,
diff --git a/libraries/compound/src/main/kotlin/io/element/android/compound/tokens/generated/SemanticColorsLight.kt b/libraries/compound/src/main/kotlin/io/element/android/compound/tokens/generated/SemanticColorsLight.kt
index 166f9ddafc..75153cb66b 100644
--- a/libraries/compound/src/main/kotlin/io/element/android/compound/tokens/generated/SemanticColorsLight.kt
+++ b/libraries/compound/src/main/kotlin/io/element/android/compound/tokens/generated/SemanticColorsLight.kt
@@ -103,6 +103,7 @@ val compoundColorsLight = SemanticColors(
iconTertiary = LightColorTokens.colorGray800,
iconTertiaryAlpha = LightColorTokens.colorAlphaGray800,
separatorPrimary = LightColorTokens.colorGray400,
+ separatorSecondary = LightColorTokens.colorGray300,
textActionAccent = LightColorTokens.colorGreen900,
textActionPrimary = LightColorTokens.colorGray1400,
textBadgeAccent = LightColorTokens.colorGreen1100,
diff --git a/libraries/compound/src/main/kotlin/io/element/android/compound/tokens/generated/SemanticColorsLightHc.kt b/libraries/compound/src/main/kotlin/io/element/android/compound/tokens/generated/SemanticColorsLightHc.kt
index 8914b41bfc..fa40a2e2ab 100644
--- a/libraries/compound/src/main/kotlin/io/element/android/compound/tokens/generated/SemanticColorsLightHc.kt
+++ b/libraries/compound/src/main/kotlin/io/element/android/compound/tokens/generated/SemanticColorsLightHc.kt
@@ -103,6 +103,7 @@ val compoundColorsHcLight = SemanticColors(
iconTertiary = LightHcColorTokens.colorGray800,
iconTertiaryAlpha = LightHcColorTokens.colorAlphaGray800,
separatorPrimary = LightHcColorTokens.colorGray400,
+ separatorSecondary = LightHcColorTokens.colorGray300,
textActionAccent = LightHcColorTokens.colorGreen900,
textActionPrimary = LightHcColorTokens.colorGray1400,
textBadgeAccent = LightHcColorTokens.colorGreen1100,
diff --git a/libraries/compound/src/main/res/drawable/ic_compound_collapse_all.xml b/libraries/compound/src/main/res/drawable/ic_compound_collapse_all.xml
new file mode 100644
index 0000000000..7257b7d2c3
--- /dev/null
+++ b/libraries/compound/src/main/res/drawable/ic_compound_collapse_all.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/libraries/compound/src/main/res/drawable/ic_compound_crop.xml b/libraries/compound/src/main/res/drawable/ic_compound_crop.xml
new file mode 100644
index 0000000000..c82f5cb686
--- /dev/null
+++ b/libraries/compound/src/main/res/drawable/ic_compound_crop.xml
@@ -0,0 +1,13 @@
+
+
+
+
diff --git a/libraries/compound/src/main/res/drawable/ic_compound_expand_all.xml b/libraries/compound/src/main/res/drawable/ic_compound_expand_all.xml
new file mode 100644
index 0000000000..fcef02880b
--- /dev/null
+++ b/libraries/compound/src/main/res/drawable/ic_compound_expand_all.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/libraries/compound/src/main/res/drawable/ic_compound_flip_horizontal.xml b/libraries/compound/src/main/res/drawable/ic_compound_flip_horizontal.xml
new file mode 100644
index 0000000000..90a3a98f44
--- /dev/null
+++ b/libraries/compound/src/main/res/drawable/ic_compound_flip_horizontal.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/libraries/compound/src/main/res/drawable/ic_compound_flip_vertical.xml b/libraries/compound/src/main/res/drawable/ic_compound_flip_vertical.xml
new file mode 100644
index 0000000000..430e5eb3fa
--- /dev/null
+++ b/libraries/compound/src/main/res/drawable/ic_compound_flip_vertical.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/libraries/compound/src/main/res/drawable/ic_compound_save.xml b/libraries/compound/src/main/res/drawable/ic_compound_save.xml
new file mode 100644
index 0000000000..3bedcc272d
--- /dev/null
+++ b/libraries/compound/src/main/res/drawable/ic_compound_save.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/libraries/compound/src/main/res/drawable/ic_compound_save_solid.xml b/libraries/compound/src/main/res/drawable/ic_compound_save_solid.xml
new file mode 100644
index 0000000000..5d453834c7
--- /dev/null
+++ b/libraries/compound/src/main/res/drawable/ic_compound_save_solid.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/libraries/compound/src/main/res/drawable/ic_compound_unsave.xml b/libraries/compound/src/main/res/drawable/ic_compound_unsave.xml
new file mode 100644
index 0000000000..0dfddcfe46
--- /dev/null
+++ b/libraries/compound/src/main/res/drawable/ic_compound_unsave.xml
@@ -0,0 +1,13 @@
+
+
+
+
diff --git a/libraries/compound/src/test/kotlin/io/element/android/compound/screenshot/AvatarColorsTest.kt b/libraries/compound/src/test/kotlin/io/element/android/compound/screenshot/AvatarColorsTest.kt
index 01c3e787cf..895247f12f 100644
--- a/libraries/compound/src/test/kotlin/io/element/android/compound/screenshot/AvatarColorsTest.kt
+++ b/libraries/compound/src/test/kotlin/io/element/android/compound/screenshot/AvatarColorsTest.kt
@@ -8,19 +8,17 @@
package io.element.android.compound.screenshot
-import androidx.test.ext.junit.runners.AndroidJUnit4
import com.github.takahirom.roborazzi.captureRoboImage
import io.element.android.compound.screenshot.utils.screenshotFile
import io.element.android.compound.theme.AvatarColorsPreviewDark
import io.element.android.compound.theme.AvatarColorsPreviewLight
+import io.element.android.tests.testutils.robolectric.RobolectricTest
import org.junit.Test
-import org.junit.runner.RunWith
import org.robolectric.annotation.Config
import org.robolectric.annotation.GraphicsMode
-@RunWith(AndroidJUnit4::class)
@GraphicsMode(GraphicsMode.Mode.NATIVE)
-class AvatarColorsTest {
+class AvatarColorsTest : RobolectricTest() {
@Test
@Config(sdk = [35], qualifiers = "xxhdpi")
fun screenshots() {
diff --git a/libraries/compound/src/test/kotlin/io/element/android/compound/screenshot/CompoundIconTest.kt b/libraries/compound/src/test/kotlin/io/element/android/compound/screenshot/CompoundIconTest.kt
index 3e8ce06cdb..97926fc291 100644
--- a/libraries/compound/src/test/kotlin/io/element/android/compound/screenshot/CompoundIconTest.kt
+++ b/libraries/compound/src/test/kotlin/io/element/android/compound/screenshot/CompoundIconTest.kt
@@ -11,7 +11,6 @@ package io.element.android.compound.screenshot
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.material3.Icon
import androidx.compose.runtime.Composable
-import androidx.test.ext.junit.runners.AndroidJUnit4
import com.github.takahirom.roborazzi.captureRoboImage
import io.element.android.compound.previews.IconsCompoundPreviewDark
import io.element.android.compound.previews.IconsCompoundPreviewLight
@@ -21,15 +20,14 @@ import io.element.android.compound.screenshot.utils.screenshotFile
import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.theme.Theme
import io.element.android.compound.tokens.generated.CompoundIcons
+import io.element.android.tests.testutils.robolectric.RobolectricTest
import kotlinx.collections.immutable.toImmutableList
import org.junit.Test
-import org.junit.runner.RunWith
import org.robolectric.annotation.Config
import org.robolectric.annotation.GraphicsMode
-@RunWith(AndroidJUnit4::class)
@GraphicsMode(GraphicsMode.Mode.NATIVE)
-class CompoundIconTest {
+class CompoundIconTest : RobolectricTest() {
@Test
@Config(sdk = [35], qualifiers = "w1024dp-h2048dp")
fun screenshots() {
diff --git a/libraries/compound/src/test/kotlin/io/element/android/compound/screenshot/CompoundTypographyTest.kt b/libraries/compound/src/test/kotlin/io/element/android/compound/screenshot/CompoundTypographyTest.kt
index c4822f680e..ec27acd836 100644
--- a/libraries/compound/src/test/kotlin/io/element/android/compound/screenshot/CompoundTypographyTest.kt
+++ b/libraries/compound/src/test/kotlin/io/element/android/compound/screenshot/CompoundTypographyTest.kt
@@ -8,18 +8,16 @@
package io.element.android.compound.screenshot
-import androidx.test.ext.junit.runners.AndroidJUnit4
import com.github.takahirom.roborazzi.captureRoboImage
import io.element.android.compound.previews.CompoundTypographyPreview
import io.element.android.compound.screenshot.utils.screenshotFile
+import io.element.android.tests.testutils.robolectric.RobolectricTest
import org.junit.Test
-import org.junit.runner.RunWith
import org.robolectric.annotation.Config
import org.robolectric.annotation.GraphicsMode
-@RunWith(AndroidJUnit4::class)
@GraphicsMode(GraphicsMode.Mode.NATIVE)
-class CompoundTypographyTest {
+class CompoundTypographyTest : RobolectricTest() {
@Test
@Config(sdk = [35], qualifiers = "h2048dp-xxhdpi")
fun screenshots() {
diff --git a/libraries/compound/src/test/kotlin/io/element/android/compound/screenshot/ForcedDarkElementThemeTest.kt b/libraries/compound/src/test/kotlin/io/element/android/compound/screenshot/ForcedDarkElementThemeTest.kt
index 74e6f3010d..5f858b8622 100644
--- a/libraries/compound/src/test/kotlin/io/element/android/compound/screenshot/ForcedDarkElementThemeTest.kt
+++ b/libraries/compound/src/test/kotlin/io/element/android/compound/screenshot/ForcedDarkElementThemeTest.kt
@@ -18,20 +18,18 @@ import androidx.compose.material3.Text
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
-import androidx.test.ext.junit.runners.AndroidJUnit4
import com.github.takahirom.roborazzi.captureRoboImage
import io.element.android.compound.colors.SemanticColorsLightDark
import io.element.android.compound.screenshot.utils.screenshotFile
import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.theme.ForcedDarkElementTheme
+import io.element.android.tests.testutils.robolectric.RobolectricTest
import org.junit.Test
-import org.junit.runner.RunWith
import org.robolectric.annotation.Config
import org.robolectric.annotation.GraphicsMode
-@RunWith(AndroidJUnit4::class)
@GraphicsMode(GraphicsMode.Mode.NATIVE)
-class ForcedDarkElementThemeTest {
+class ForcedDarkElementThemeTest : RobolectricTest() {
@Test
@Config(sdk = [35], qualifiers = "xxhdpi")
fun screenshots() {
diff --git a/libraries/compound/src/test/kotlin/io/element/android/compound/screenshot/LegacyColorsTest.kt b/libraries/compound/src/test/kotlin/io/element/android/compound/screenshot/LegacyColorsTest.kt
index deb27c6519..c0c3d11555 100644
--- a/libraries/compound/src/test/kotlin/io/element/android/compound/screenshot/LegacyColorsTest.kt
+++ b/libraries/compound/src/test/kotlin/io/element/android/compound/screenshot/LegacyColorsTest.kt
@@ -18,7 +18,6 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
-import androidx.test.ext.junit.runners.AndroidJUnit4
import com.github.takahirom.roborazzi.captureRoboImage
import io.element.android.compound.previews.ColorPreview
import io.element.android.compound.screenshot.utils.screenshotFile
@@ -26,14 +25,13 @@ import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.theme.LinkColor
import io.element.android.compound.theme.SnackBarLabelColorDark
import io.element.android.compound.theme.SnackBarLabelColorLight
+import io.element.android.tests.testutils.robolectric.RobolectricTest
import org.junit.Test
-import org.junit.runner.RunWith
import org.robolectric.annotation.Config
import org.robolectric.annotation.GraphicsMode
-@RunWith(AndroidJUnit4::class)
@GraphicsMode(GraphicsMode.Mode.NATIVE)
-class LegacyColorsTest {
+class LegacyColorsTest : RobolectricTest() {
@Test
@Config(sdk = [35], qualifiers = "xxhdpi")
fun screenshots() {
diff --git a/libraries/compound/src/test/kotlin/io/element/android/compound/screenshot/MaterialColorSchemeTest.kt b/libraries/compound/src/test/kotlin/io/element/android/compound/screenshot/MaterialColorSchemeTest.kt
index 282ca0e69a..2e1368ff54 100644
--- a/libraries/compound/src/test/kotlin/io/element/android/compound/screenshot/MaterialColorSchemeTest.kt
+++ b/libraries/compound/src/test/kotlin/io/element/android/compound/screenshot/MaterialColorSchemeTest.kt
@@ -18,7 +18,6 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
-import androidx.test.ext.junit.runners.AndroidJUnit4
import com.github.takahirom.roborazzi.captureRoboImage
import io.element.android.compound.screenshot.utils.screenshotFile
import io.element.android.compound.theme.ColorsSchemeDarkHcPreview
@@ -26,14 +25,13 @@ import io.element.android.compound.theme.ColorsSchemeDarkPreview
import io.element.android.compound.theme.ColorsSchemeLightHcPreview
import io.element.android.compound.theme.ColorsSchemeLightPreview
import io.element.android.compound.theme.ElementTheme
+import io.element.android.tests.testutils.robolectric.RobolectricTest
import org.junit.Test
-import org.junit.runner.RunWith
import org.robolectric.annotation.Config
import org.robolectric.annotation.GraphicsMode
-@RunWith(AndroidJUnit4::class)
@GraphicsMode(GraphicsMode.Mode.NATIVE)
-class MaterialColorSchemeTest {
+class MaterialColorSchemeTest : RobolectricTest() {
@Test
@Config(sdk = [35], qualifiers = "h2048dp-xhdpi")
fun screenshots() {
diff --git a/libraries/compound/src/test/kotlin/io/element/android/compound/screenshot/MaterialTextTest.kt b/libraries/compound/src/test/kotlin/io/element/android/compound/screenshot/MaterialTextTest.kt
index 2aaeed43a5..4d21e77aed 100644
--- a/libraries/compound/src/test/kotlin/io/element/android/compound/screenshot/MaterialTextTest.kt
+++ b/libraries/compound/src/test/kotlin/io/element/android/compound/screenshot/MaterialTextTest.kt
@@ -8,18 +8,16 @@
package io.element.android.compound.screenshot
-import androidx.test.ext.junit.runners.AndroidJUnit4
import com.github.takahirom.roborazzi.captureRoboImage
import io.element.android.compound.screenshot.utils.screenshotFile
import io.element.android.compound.theme.MaterialTextPreview
+import io.element.android.tests.testutils.robolectric.RobolectricTest
import org.junit.Test
-import org.junit.runner.RunWith
import org.robolectric.annotation.Config
import org.robolectric.annotation.GraphicsMode
-@RunWith(AndroidJUnit4::class)
@GraphicsMode(GraphicsMode.Mode.NATIVE)
-class MaterialTextTest {
+class MaterialTextTest : RobolectricTest() {
@Test
@Config(sdk = [35], qualifiers = "w480dp-h1200dp-xxhdpi")
fun screenshots() {
diff --git a/libraries/compound/src/test/kotlin/io/element/android/compound/screenshot/MaterialTypographyTest.kt b/libraries/compound/src/test/kotlin/io/element/android/compound/screenshot/MaterialTypographyTest.kt
index 3d8256e2af..3c07ff48f5 100644
--- a/libraries/compound/src/test/kotlin/io/element/android/compound/screenshot/MaterialTypographyTest.kt
+++ b/libraries/compound/src/test/kotlin/io/element/android/compound/screenshot/MaterialTypographyTest.kt
@@ -8,18 +8,16 @@
package io.element.android.compound.screenshot
-import androidx.test.ext.junit.runners.AndroidJUnit4
import com.github.takahirom.roborazzi.captureRoboImage
import io.element.android.compound.previews.TypographyPreview
import io.element.android.compound.screenshot.utils.screenshotFile
+import io.element.android.tests.testutils.robolectric.RobolectricTest
import org.junit.Test
-import org.junit.runner.RunWith
import org.robolectric.annotation.Config
import org.robolectric.annotation.GraphicsMode
-@RunWith(AndroidJUnit4::class)
@GraphicsMode(GraphicsMode.Mode.NATIVE)
-class MaterialTypographyTest {
+class MaterialTypographyTest : RobolectricTest() {
@Test
@Config(sdk = [35], qualifiers = "h2048dp-xxhdpi")
fun screenshots() {
diff --git a/libraries/compound/src/test/kotlin/io/element/android/compound/screenshot/MaterialYouThemeTest.kt b/libraries/compound/src/test/kotlin/io/element/android/compound/screenshot/MaterialYouThemeTest.kt
index dcbbc9d4ac..2f1abc55f5 100644
--- a/libraries/compound/src/test/kotlin/io/element/android/compound/screenshot/MaterialYouThemeTest.kt
+++ b/libraries/compound/src/test/kotlin/io/element/android/compound/screenshot/MaterialYouThemeTest.kt
@@ -19,20 +19,18 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
-import androidx.test.ext.junit.runners.AndroidJUnit4
import com.github.takahirom.roborazzi.captureRoboImage
import io.element.android.compound.previews.ColorsSchemePreview
import io.element.android.compound.screenshot.utils.screenshotFile
import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.theme.Theme
+import io.element.android.tests.testutils.robolectric.RobolectricTest
import org.junit.Test
-import org.junit.runner.RunWith
import org.robolectric.annotation.Config
import org.robolectric.annotation.GraphicsMode
-@RunWith(AndroidJUnit4::class)
@GraphicsMode(GraphicsMode.Mode.NATIVE)
-class MaterialYouThemeTest {
+class MaterialYouThemeTest : RobolectricTest() {
@Test
@Config(sdk = [35], qualifiers = "h2048dp-xhdpi")
fun screenshots() {
diff --git a/libraries/compound/src/test/kotlin/io/element/android/compound/screenshot/SemanticColorsTest.kt b/libraries/compound/src/test/kotlin/io/element/android/compound/screenshot/SemanticColorsTest.kt
index f36d0d2ee5..95e34fb3d9 100644
--- a/libraries/compound/src/test/kotlin/io/element/android/compound/screenshot/SemanticColorsTest.kt
+++ b/libraries/compound/src/test/kotlin/io/element/android/compound/screenshot/SemanticColorsTest.kt
@@ -8,21 +8,19 @@
package io.element.android.compound.screenshot
-import androidx.test.ext.junit.runners.AndroidJUnit4
import com.github.takahirom.roborazzi.captureRoboImage
import io.element.android.compound.previews.CompoundSemanticColorsDark
import io.element.android.compound.previews.CompoundSemanticColorsDarkHc
import io.element.android.compound.previews.CompoundSemanticColorsLight
import io.element.android.compound.previews.CompoundSemanticColorsLightHc
import io.element.android.compound.screenshot.utils.screenshotFile
+import io.element.android.tests.testutils.robolectric.RobolectricTest
import org.junit.Test
-import org.junit.runner.RunWith
import org.robolectric.annotation.Config
import org.robolectric.annotation.GraphicsMode
-@RunWith(AndroidJUnit4::class)
@GraphicsMode(GraphicsMode.Mode.NATIVE)
-class SemanticColorsTest {
+class SemanticColorsTest : RobolectricTest() {
@Config(sdk = [35], qualifiers = "h2000dp-xhdpi")
@Test
fun screenshots() {
diff --git a/libraries/dateformatter/impl/src/test/kotlin/io/element/android/libraries/dateformatter/impl/DefaultDateFormatterFrTest.kt b/libraries/dateformatter/impl/src/test/kotlin/io/element/android/libraries/dateformatter/impl/DefaultDateFormatterFrTest.kt
index b4800729d5..dfea6adc46 100644
--- a/libraries/dateformatter/impl/src/test/kotlin/io/element/android/libraries/dateformatter/impl/DefaultDateFormatterFrTest.kt
+++ b/libraries/dateformatter/impl/src/test/kotlin/io/element/android/libraries/dateformatter/impl/DefaultDateFormatterFrTest.kt
@@ -9,17 +9,15 @@
package io.element.android.libraries.dateformatter.impl
import android.os.Build
-import androidx.test.ext.junit.runners.AndroidJUnit4
import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.dateformatter.api.DateFormatterMode
+import io.element.android.tests.testutils.robolectric.RobolectricTest
import org.junit.Test
-import org.junit.runner.RunWith
import org.robolectric.annotation.Config
import kotlin.time.Instant
-@RunWith(AndroidJUnit4::class)
@Config(qualifiers = "fr", sdk = [Build.VERSION_CODES.TIRAMISU])
-class DefaultDateFormatterFrTest {
+class DefaultDateFormatterFrTest : RobolectricTest() {
@Test
fun `test null`() {
val now = "1980-04-06T18:35:24.00Z"
diff --git a/libraries/dateformatter/impl/src/test/kotlin/io/element/android/libraries/dateformatter/impl/DefaultDateFormatterTest.kt b/libraries/dateformatter/impl/src/test/kotlin/io/element/android/libraries/dateformatter/impl/DefaultDateFormatterTest.kt
index 94b6fe983e..f761d833ab 100644
--- a/libraries/dateformatter/impl/src/test/kotlin/io/element/android/libraries/dateformatter/impl/DefaultDateFormatterTest.kt
+++ b/libraries/dateformatter/impl/src/test/kotlin/io/element/android/libraries/dateformatter/impl/DefaultDateFormatterTest.kt
@@ -9,17 +9,15 @@
package io.element.android.libraries.dateformatter.impl
import android.os.Build
-import androidx.test.ext.junit.runners.AndroidJUnit4
import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.dateformatter.api.DateFormatterMode
+import io.element.android.tests.testutils.robolectric.RobolectricTest
import org.junit.Test
-import org.junit.runner.RunWith
import org.robolectric.annotation.Config
import kotlin.time.Instant
-@RunWith(AndroidJUnit4::class)
@Config(qualifiers = "en", sdk = [Build.VERSION_CODES.TIRAMISU])
-class DefaultDateFormatterTest {
+class DefaultDateFormatterTest : RobolectricTest() {
@Test
fun `test null`() {
val now = "1980-04-06T18:35:24.00Z"
diff --git a/libraries/dateformatter/impl/src/test/kotlin/io/element/android/libraries/dateformatter/impl/DefaultDurationFormatterTest.kt b/libraries/dateformatter/impl/src/test/kotlin/io/element/android/libraries/dateformatter/impl/DefaultDurationFormatterTest.kt
index 3fee5dbb0d..6b6fbbb2c1 100644
--- a/libraries/dateformatter/impl/src/test/kotlin/io/element/android/libraries/dateformatter/impl/DefaultDurationFormatterTest.kt
+++ b/libraries/dateformatter/impl/src/test/kotlin/io/element/android/libraries/dateformatter/impl/DefaultDurationFormatterTest.kt
@@ -8,10 +8,9 @@
package io.element.android.libraries.dateformatter.impl
import android.os.Build
-import androidx.test.ext.junit.runners.AndroidJUnit4
import com.google.common.truth.Truth.assertThat
+import io.element.android.tests.testutils.robolectric.RobolectricTest
import org.junit.Test
-import org.junit.runner.RunWith
import org.robolectric.annotation.Config
import java.util.Locale
import kotlin.time.Duration.Companion.hours
@@ -19,9 +18,8 @@ import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Duration.Companion.minutes
import kotlin.time.Duration.Companion.seconds
-@RunWith(AndroidJUnit4::class)
@Config(qualifiers = "en", sdk = [Build.VERSION_CODES.TIRAMISU])
-class DefaultDurationFormatterTest {
+class DefaultDurationFormatterTest : RobolectricTest() {
private fun createDurationFormatter(): DefaultDurationFormatter {
return DefaultDurationFormatter(
localeChangeObserver = {},
diff --git a/libraries/deeplink/impl/src/test/kotlin/io/element/android/libraries/deeplink/impl/DefaultDeeplinkParserTest.kt b/libraries/deeplink/impl/src/test/kotlin/io/element/android/libraries/deeplink/impl/DefaultDeeplinkParserTest.kt
index 7f563ddd7f..a6502aabf1 100644
--- a/libraries/deeplink/impl/src/test/kotlin/io/element/android/libraries/deeplink/impl/DefaultDeeplinkParserTest.kt
+++ b/libraries/deeplink/impl/src/test/kotlin/io/element/android/libraries/deeplink/impl/DefaultDeeplinkParserTest.kt
@@ -21,12 +21,10 @@ import io.element.android.libraries.matrix.test.A_ROOM_ID
import io.element.android.libraries.matrix.test.A_SESSION_ID
import io.element.android.libraries.matrix.test.A_THREAD_ID
import io.element.android.tests.testutils.assertThrowsInDebug
+import io.element.android.tests.testutils.robolectric.RobolectricTest
import org.junit.Test
-import org.junit.runner.RunWith
-import org.robolectric.RobolectricTestRunner
-@RunWith(RobolectricTestRunner::class)
-class DefaultDeeplinkParserTest {
+class DefaultDeeplinkParserTest : RobolectricTest() {
companion object {
const val A_URI =
"elementx://open/@alice:server.org"
diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/atoms/CounterAtom.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/atoms/CounterAtom.kt
index a051fd4c80..3bbd3c12a7 100644
--- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/atoms/CounterAtom.kt
+++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/atoms/CounterAtom.kt
@@ -18,6 +18,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.rememberTextMeasurer
import androidx.compose.ui.unit.dp
@@ -37,13 +38,18 @@ private const val MAX_COUNT_STRING = "+$MAX_COUNT"
* @param count The number to display. If the number is greater than [MAX_COUNT], the counter will display [MAX_COUNT_STRING].
* If the number is less than 1, the counter will not be displayed.
* @param modifier The modifier to apply to this layout.
+ * @param containerColor The background color of the counter. When null, uses [isCritical] to pick a default.
+ * @param contentColor The text color inside the counter. When null, uses [textOnSolidPrimary].
* @param textStyle The style to apply to the text inside the counter.
* @param isCritical If true, the counter will use a critical color scheme, otherwise it will use an accent color scheme.
+ * Only used when [containerColor] is null.
*/
@Composable
fun CounterAtom(
count: Int,
modifier: Modifier = Modifier,
+ containerColor: Color? = null,
+ contentColor: Color? = null,
textStyle: TextStyle = CounterAtomDefaults.textStyle,
isCritical: Boolean = false,
) {
@@ -65,7 +71,7 @@ fun CounterAtom(
.size(squareSize.toDp() + 1.dp)
.clip(CircleShape)
.background(
- if (isCritical) {
+ containerColor ?: if (isCritical) {
ElementTheme.colors.iconCriticalPrimary
} else {
ElementTheme.colors.iconAccentPrimary
@@ -76,7 +82,7 @@ fun CounterAtom(
modifier = Modifier.align(Alignment.Center),
text = countAsText,
style = textStyle,
- color = ElementTheme.colors.textOnSolidPrimary,
+ color = contentColor ?: ElementTheme.colors.textOnSolidPrimary,
)
}
}
diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/atoms/UnreadIndicatorAtom.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/atoms/UnreadIndicatorAtom.kt
index d2db3aec8e..bdf039edb8 100644
--- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/atoms/UnreadIndicatorAtom.kt
+++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/atoms/UnreadIndicatorAtom.kt
@@ -8,8 +8,11 @@
package io.element.android.libraries.designsystem.atomic.atoms
+import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background
+import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.runtime.Composable
@@ -29,19 +32,36 @@ import io.element.android.libraries.designsystem.theme.unreadIndicator
fun UnreadIndicatorAtom(
modifier: Modifier = Modifier,
size: Dp = 12.dp,
+ count: Long? = null,
color: Color = ElementTheme.colors.unreadIndicator,
isVisible: Boolean = true,
contentDescription: String? = null,
+ border: BorderStroke? = null,
) {
- Box(
- modifier = modifier
- .semantics {
- contentDescription?.let { this.contentDescription = it }
- }
- .size(size)
- .clip(CircleShape)
- .background(if (isVisible) color else Color.Transparent)
- )
+ when {
+ !isVisible -> Spacer(modifier = modifier.size(size))
+ count != null && count >= 1 -> CounterAtom(
+ count = count.toInt(),
+ modifier = modifier
+ .semantics {
+ contentDescription?.let { this.contentDescription = it }
+ }
+ .then(if (border != null) Modifier.border(border, CircleShape) else Modifier),
+ containerColor = color,
+ contentColor = ElementTheme.colors.bgCanvasDefault,
+ textStyle = ElementTheme.typography.fontBodySmMedium,
+ )
+ else -> Box(
+ modifier = modifier
+ .semantics {
+ contentDescription?.let { this.contentDescription = it }
+ }
+ .size(size)
+ .clip(CircleShape)
+ .background(color)
+ .then(if (border != null) Modifier.border(border, CircleShape) else Modifier),
+ )
+ }
}
@PreviewsDayNight
diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/SimpleModalBottomSheet.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/SimpleModalBottomSheet.kt
index 9c3930ac64..284d583de8 100644
--- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/SimpleModalBottomSheet.kt
+++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/SimpleModalBottomSheet.kt
@@ -17,7 +17,8 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.ExperimentalMaterial3Api
-import androidx.compose.material3.rememberModalBottomSheetState
+import androidx.compose.material3.SheetValue
+import androidx.compose.material3.rememberBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.datasource.LoremIpsum
@@ -39,7 +40,10 @@ fun SimpleModalBottomSheet(
ModalBottomSheet(
onDismissRequest = onDismiss,
modifier = modifier,
- sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true),
+ sheetState = rememberBottomSheetState(
+ initialValue = SheetValue.Hidden,
+ enabledValues = setOf(SheetValue.Hidden, SheetValue.Expanded),
+ ),
scrollable = false,
) {
Column(
@@ -61,7 +65,7 @@ fun SimpleModalBottomSheet(
@PreviewsDayNight
@Composable
-internal fun SimpleModalBottomSheetPreview() = ElementPreview {
+internal fun SimpleModalBottomSheetPreview() = ElementPreview(fillMaxSize = true) {
SimpleModalBottomSheet(title = "A title", onDismiss = {}) {
Text(
text = LoremIpsum(20).values.first(),
diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/organisms/InfoListOrganism.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/visuallist/VisualList.kt
similarity index 71%
rename from libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/organisms/InfoListOrganism.kt
rename to libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/visuallist/VisualList.kt
index f52ee2512f..0b5e63dcbe 100644
--- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/organisms/InfoListOrganism.kt
+++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/visuallist/VisualList.kt
@@ -6,25 +6,19 @@
* Please see LICENSE files in the repository root for full details.
*/
-package io.element.android.libraries.designsystem.atomic.organisms
+package io.element.android.libraries.designsystem.components.visuallist
-import androidx.annotation.DrawableRes
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.size
-import androidx.compose.material3.LocalContentColor
-import androidx.compose.material3.LocalTextStyle
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
-import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
-import io.element.android.libraries.designsystem.atomic.molecules.InfoListItemMolecule
-import io.element.android.libraries.designsystem.atomic.molecules.InfoListItemPosition
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.Icon
@@ -32,14 +26,17 @@ import io.element.android.libraries.designsystem.theme.components.Text
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
+/**
+ * Ref: https://www.figma.com/design/G1xy0HDZKJf5TCRFmKb5d5/Compound-Android-Components?node-id=2001-140
+ */
@Composable
-fun InfoListOrganism(
- items: ImmutableList,
+fun VisualList(
+ items: ImmutableList,
modifier: Modifier = Modifier,
backgroundColor: Color = ElementTheme.colors.bgSubtleSecondary,
- iconTint: Color = LocalContentColor.current,
+ iconTint: Color = ElementTheme.colors.iconSecondary,
iconSize: Dp = 20.dp,
- textStyle: TextStyle = LocalTextStyle.current,
+ textStyle: TextStyle = ElementTheme.typography.fontBodyMdMedium,
textColor: Color = ElementTheme.colors.textPrimary,
verticalArrangement: Arrangement.Vertical = Arrangement.spacedBy(4.dp),
) {
@@ -49,12 +46,12 @@ fun InfoListOrganism(
) {
for ((index, item) in items.withIndex()) {
val position = when {
- items.size == 1 -> InfoListItemPosition.Single
- index == 0 -> InfoListItemPosition.Top
- index == items.size - 1 -> InfoListItemPosition.Bottom
- else -> InfoListItemPosition.Middle
+ items.size == 1 -> VisualListItemPosition.Single
+ index == 0 -> VisualListItemPosition.Top
+ index == items.size - 1 -> VisualListItemPosition.Bottom
+ else -> VisualListItemPosition.Middle
}
- InfoListItemMolecule(
+ VisualListItem(
message = {
if (item.message is AnnotatedString) {
Text(
@@ -96,22 +93,15 @@ fun InfoListOrganism(
}
}
-data class InfoListItem(
- val message: CharSequence,
- @DrawableRes val iconId: Int? = null,
- val iconVector: ImageVector? = null,
- val iconComposable: @Composable () -> Unit = {},
-)
-
@PreviewsDayNight
@Composable
-internal fun InfoListOrganismPreview() = ElementPreview {
+internal fun VisualListPreview() = ElementPreview {
val items = persistentListOf(
- InfoListItem(message = "A top item"),
- InfoListItem(message = "A middle item"),
- InfoListItem(message = "A bottom item"),
+ VisualListItemData(message = "A top item"),
+ VisualListItemData(message = "A middle item"),
+ VisualListItemData(message = "A bottom item"),
)
- InfoListOrganism(
+ VisualList(
items = items,
)
}
diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/molecules/InfoListItemMolecule.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/visuallist/VisualListItem.kt
similarity index 74%
rename from libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/molecules/InfoListItemMolecule.kt
rename to libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/visuallist/VisualListItem.kt
index 46d8029c89..276fb1522e 100644
--- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/molecules/InfoListItemMolecule.kt
+++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/visuallist/VisualListItem.kt
@@ -6,7 +6,7 @@
* Please see LICENSE files in the repository root for full details.
*/
-package io.element.android.libraries.designsystem.atomic.molecules
+package io.element.android.libraries.designsystem.components.visuallist
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
@@ -27,10 +27,13 @@ import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.Text
+/**
+ * Ref: https://www.figma.com/design/G1xy0HDZKJf5TCRFmKb5d5/Compound-Android-Components?node-id=2001-159
+ */
@Composable
-fun InfoListItemMolecule(
+fun VisualListItem(
message: @Composable () -> Unit,
- position: InfoListItemPosition,
+ position: VisualListItemPosition,
backgroundColor: Color,
modifier: Modifier = Modifier,
icon: @Composable () -> Unit = {},
@@ -38,10 +41,10 @@ fun InfoListItemMolecule(
val radius = 14.dp
val backgroundShape = remember(position) {
when (position) {
- InfoListItemPosition.Single -> RoundedCornerShape(radius)
- InfoListItemPosition.Top -> RoundedCornerShape(topStart = radius, topEnd = radius)
- InfoListItemPosition.Middle -> RoundedCornerShape(0.dp)
- InfoListItemPosition.Bottom -> RoundedCornerShape(bottomStart = radius, bottomEnd = radius)
+ VisualListItemPosition.Single -> RoundedCornerShape(radius)
+ VisualListItemPosition.Top -> RoundedCornerShape(topStart = radius, topEnd = radius)
+ VisualListItemPosition.Middle -> RoundedCornerShape(0.dp)
+ VisualListItemPosition.Bottom -> RoundedCornerShape(bottomStart = radius, bottomEnd = radius)
}
}
Row(
@@ -61,42 +64,42 @@ fun InfoListItemMolecule(
@PreviewsDayNight
@Composable
-internal fun InfoListItemMoleculePreview() {
+internal fun VisualListItemPreview() {
ElementPreview {
val color = ElementTheme.colors.bgSubtleSecondary
Column(
modifier = Modifier.padding(10.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
- InfoListItemMolecule(
+ VisualListItem(
message = { Text("A single item") },
icon = { Icon(imageVector = CompoundIcons.InfoSolid(), contentDescription = null) },
- position = InfoListItemPosition.Single,
+ position = VisualListItemPosition.Single,
backgroundColor = color,
)
- InfoListItemMolecule(
+ VisualListItem(
message = { Text("A top item") },
icon = { Icon(imageVector = CompoundIcons.InfoSolid(), contentDescription = null) },
- position = InfoListItemPosition.Top,
+ position = VisualListItemPosition.Top,
backgroundColor = color,
)
- InfoListItemMolecule(
+ VisualListItem(
message = { Text("A middle item") },
icon = { Icon(imageVector = CompoundIcons.InfoSolid(), contentDescription = null) },
- position = InfoListItemPosition.Middle,
+ position = VisualListItemPosition.Middle,
backgroundColor = color,
)
- InfoListItemMolecule(
+ VisualListItem(
message = { Text("A bottom item") },
icon = { Icon(imageVector = CompoundIcons.InfoSolid(), contentDescription = null) },
- position = InfoListItemPosition.Bottom,
+ position = VisualListItemPosition.Bottom,
backgroundColor = color,
)
}
}
}
-enum class InfoListItemPosition {
+enum class VisualListItemPosition {
Top,
Middle,
Bottom,
diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/visuallist/VisualListItemData.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/visuallist/VisualListItemData.kt
new file mode 100644
index 0000000000..ace971ac61
--- /dev/null
+++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/visuallist/VisualListItemData.kt
@@ -0,0 +1,19 @@
+/*
+ * Copyright (c) 2026 Element Creations Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
+ * Please see LICENSE files in the repository root for full details.
+ */
+
+package io.element.android.libraries.designsystem.components.visuallist
+
+import androidx.annotation.DrawableRes
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.graphics.vector.ImageVector
+
+data class VisualListItemData(
+ val message: CharSequence,
+ @DrawableRes val iconId: Int? = null,
+ val iconVector: ImageVector? = null,
+ val iconComposable: @Composable () -> Unit = {},
+)
diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/modifiers/BringIntoViewOnImeVisible.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/modifiers/BringIntoViewOnImeVisible.kt
new file mode 100644
index 0000000000..155172c96f
--- /dev/null
+++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/modifiers/BringIntoViewOnImeVisible.kt
@@ -0,0 +1,49 @@
+/*
+ * Copyright (c) 2025 Element Creations Ltd.
+ * Copyright 2025 New Vector Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
+ * Please see LICENSE files in the repository root for full details.
+ */
+
+package io.element.android.libraries.designsystem.modifiers
+
+import androidx.compose.foundation.layout.ExperimentalLayoutApi
+import androidx.compose.foundation.layout.WindowInsets
+import androidx.compose.foundation.layout.isImeVisible
+import androidx.compose.foundation.relocation.BringIntoViewRequester
+import androidx.compose.foundation.relocation.bringIntoViewRequester
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.focus.onFocusChanged
+import kotlinx.coroutines.delay
+import kotlin.time.Duration.Companion.milliseconds
+
+/**
+ * Keeps the focused field visible above the keyboard. Intended for a field inside a scrollable
+ * container: the field is never clipped by a pinned footer, but the IME is shown only after focus
+ * arrives, so we re-request bringIntoView once it is visible to scroll the field back into the
+ * reduced viewport.
+ */
+@OptIn(ExperimentalLayoutApi::class)
+@Composable
+fun Modifier.bringIntoViewOnImeVisible(): Modifier {
+ val bringIntoViewRequester = remember { BringIntoViewRequester() }
+ var isFocused by remember { mutableStateOf(false) }
+ val isImeVisible = WindowInsets.isImeVisible
+ LaunchedEffect(isImeVisible, isFocused) {
+ if (isImeVisible && isFocused) {
+ // Delay to ensure the keyboard is fully shown before scrolling the field into view.
+ delay(100.milliseconds)
+ bringIntoViewRequester.bringIntoView()
+ }
+ }
+ return this
+ .bringIntoViewRequester(bringIntoViewRequester)
+ .onFocusChanged { isFocused = it.isFocused }
+}
diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/preview/ElementPreview.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/preview/ElementPreview.kt
index e4030a77ea..693b3f5b79 100644
--- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/preview/ElementPreview.kt
+++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/preview/ElementPreview.kt
@@ -10,8 +10,13 @@ package io.element.android.libraries.designsystem.preview
import androidx.annotation.DrawableRes
import androidx.compose.foundation.isSystemInDarkTheme
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
+import androidx.compose.runtime.movableContentOf
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.core.content.res.ResourcesCompat
import coil3.annotation.ExperimentalCoilApi
@@ -29,6 +34,7 @@ import io.element.android.libraries.designsystem.utils.CommonDrawables
fun ElementPreview(
theme: Theme = if (isSystemInDarkTheme()) Theme.Dark else Theme.Light,
showBackground: Boolean = true,
+ fillMaxSize: Boolean = false,
@DrawableRes
drawableFallbackForImages: Int = CommonDrawables.sample_background,
content: @Composable () -> Unit
@@ -40,11 +46,21 @@ fun ElementPreview(
}
) {
ElementTheme(theme = theme) {
+ val movableContent = remember { movableContentOf { content() } }
if (showBackground) {
// If we have a proper contentColor applied we need a Surface instead of a Box
- Surface(content = content)
+ Surface(
+ modifier = if (fillMaxSize) Modifier.fillMaxSize() else Modifier,
+ content = movableContent,
+ )
} else {
- content()
+ if (fillMaxSize) {
+ Box(modifier = Modifier.fillMaxSize()) {
+ movableContent()
+ }
+ } else {
+ movableContent()
+ }
}
}
}
diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/preview/PreviewWithLargeHeight.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/preview/PreviewWithLargeHeight.kt
index 163ee21590..8d745ec0e7 100644
--- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/preview/PreviewWithLargeHeight.kt
+++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/preview/PreviewWithLargeHeight.kt
@@ -16,3 +16,6 @@ import androidx.compose.ui.tooling.preview.Preview
*/
@Preview(heightDp = 1000)
annotation class PreviewWithLargeHeight
+
+@Preview(heightDp = 1600)
+annotation class PreviewWithExtraLargeHeight
diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/preview/SheetState.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/preview/SheetState.kt
index 7e687e9b89..8d03cfb7a5 100644
--- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/preview/SheetState.kt
+++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/preview/SheetState.kt
@@ -11,12 +11,10 @@ package io.element.android.libraries.designsystem.preview
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.SheetState
import androidx.compose.material3.SheetValue
-import androidx.compose.runtime.Composable
@OptIn(ExperimentalMaterial3Api::class)
-@Composable
fun sheetStateForPreview() = SheetState(
- skipPartiallyExpanded = true,
+ enabledValues = setOf(SheetValue.Hidden, SheetValue.Expanded),
positionalThreshold = { 0.5f },
velocityThreshold = { 400f },
initialValue = SheetValue.Expanded,
diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/HorizontalDivider.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/HorizontalDivider.kt
index b4c7eddf31..0a3d717b09 100644
--- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/HorizontalDivider.kt
+++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/HorizontalDivider.kt
@@ -10,7 +10,6 @@ package io.element.android.libraries.designsystem.theme.components
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.padding
-import androidx.compose.material3.DividerDefaults
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@@ -18,6 +17,7 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
+import io.element.android.compound.theme.ElementTheme
import io.element.android.libraries.designsystem.preview.ElementThemedPreview
import io.element.android.libraries.designsystem.preview.PreviewGroup
@@ -25,7 +25,7 @@ import io.element.android.libraries.designsystem.preview.PreviewGroup
fun HorizontalDivider(
modifier: Modifier = Modifier,
thickness: Dp = ElementDividerDefaults.thickness,
- color: Color = DividerDefaults.color,
+ color: Color = ElementDividerDefaults.color,
) {
androidx.compose.material3.HorizontalDivider(
modifier = modifier,
@@ -35,7 +35,9 @@ fun HorizontalDivider(
}
object ElementDividerDefaults {
- val thickness = 0.5.dp
+ val thickness = 1.dp
+ val color
+ @Composable get() = ElementTheme.colors.separatorSecondary
}
@Preview(group = PreviewGroup.Dividers)
diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/ModalBottomSheet.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/ModalBottomSheet.kt
index ec5f7ce456..6cb49a6218 100644
--- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/ModalBottomSheet.kt
+++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/ModalBottomSheet.kt
@@ -21,8 +21,9 @@ import androidx.compose.material3.BottomSheetDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SheetState
+import androidx.compose.material3.SheetValue
import androidx.compose.material3.contentColorFor
-import androidx.compose.material3.rememberModalBottomSheetState
+import androidx.compose.material3.rememberBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.movableContentOf
import androidx.compose.runtime.remember
@@ -57,7 +58,9 @@ fun ModalBottomSheet(
onDismissRequest: () -> Unit,
scrollable: Boolean,
modifier: Modifier = Modifier,
- sheetState: SheetState = rememberModalBottomSheetState(),
+ sheetState: SheetState = rememberBottomSheetState(
+ initialValue = SheetValue.Hidden,
+ ),
shape: Shape = BottomSheetDefaults.ExpandedShape,
containerColor: Color = MaterialTheme.colorScheme.surface,
contentColor: Color = contentColorFor(containerColor),
diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/PasswordVisibilityToggle.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/PasswordVisibilityToggle.kt
new file mode 100644
index 0000000000..9154ec6292
--- /dev/null
+++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/PasswordVisibilityToggle.kt
@@ -0,0 +1,37 @@
+/*
+ * Copyright (c) 2026 Element Creations Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
+ * Please see LICENSE files in the repository root for full details.
+ */
+
+package io.element.android.libraries.designsystem.theme.components
+
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Box
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+import io.element.android.compound.tokens.generated.CompoundIcons
+import io.element.android.libraries.ui.strings.CommonStrings
+
+/**
+ * Show/hide toggle for a password [TextField], intended for its `trailingIcon` slot.
+ * Shared so every password field reveals plaintext the same way and announces the
+ * same accessibility labels.
+ */
+@Composable
+fun PasswordVisibilityToggle(
+ visible: Boolean,
+ onToggle: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ Box(modifier.clickable(onClick = onToggle)) {
+ Icon(
+ imageVector = if (visible) CompoundIcons.VisibilityOn() else CompoundIcons.VisibilityOff(),
+ contentDescription = stringResource(
+ if (visible) CommonStrings.a11y_hide_password else CommonStrings.a11y_show_password
+ ),
+ )
+ }
+}
diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/utils/snackbar/SnackbarDispatcher.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/utils/snackbar/SnackbarDispatcher.kt
index 56bb070228..566c92d5cf 100644
--- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/utils/snackbar/SnackbarDispatcher.kt
+++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/utils/snackbar/SnackbarDispatcher.kt
@@ -18,39 +18,35 @@ import androidx.compose.runtime.remember
import androidx.compose.ui.res.stringResource
import io.element.android.libraries.designsystem.theme.components.Snackbar
import kotlinx.coroutines.CancellationException
-import kotlinx.coroutines.currentCoroutineContext
import kotlinx.coroutines.flow.Flow
-import kotlinx.coroutines.flow.flow
-import kotlinx.coroutines.isActive
-import kotlinx.coroutines.sync.Mutex
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.asStateFlow
/**
* A global dispatcher of [SnackbarMessage] to be displayed in [Snackbar] via a [SnackbarHostState].
+ *
+ * The current head of the queue is exposed as a [MutableStateFlow] so that every collector — including
+ * hosts that subscribe *after* a message was posted (e.g. a screen recomposing as a flow pops back to
+ * it) — observes the current message. An earlier mutex-gated implementation delivered each message to
+ * whichever single collector happened to be parked on the lock, which could starve the host that was
+ * actually on screen, dropping the snackbar.
*/
class SnackbarDispatcher {
- private val queueMutex = Mutex()
private val snackBarMessageQueue = ArrayDeque()
- val snackbarMessage: Flow = flow {
- while (currentCoroutineContext().isActive) {
- queueMutex.lock()
- emit(snackBarMessageQueue.firstOrNull())
- }
- }
+ private val currentMessage = MutableStateFlow(null)
+ val snackbarMessage: Flow = currentMessage.asStateFlow()
+
+ @Synchronized
fun post(message: SnackbarMessage) {
- if (snackBarMessageQueue.isEmpty()) {
- snackBarMessageQueue.add(message)
- if (queueMutex.isLocked) queueMutex.unlock()
- } else {
- snackBarMessageQueue.add(message)
- }
+ snackBarMessageQueue.add(message)
+ currentMessage.value = snackBarMessageQueue.firstOrNull()
}
+ @Synchronized
fun clear() {
- if (snackBarMessageQueue.isNotEmpty()) {
- snackBarMessageQueue.removeFirstOrNull()
- if (queueMutex.isLocked) queueMutex.unlock()
- }
+ snackBarMessageQueue.removeFirstOrNull()
+ currentMessage.value = snackBarMessageQueue.firstOrNull()
}
}
diff --git a/libraries/designsystem/src/main/res/drawable-night/sample_map.png b/libraries/designsystem/src/main/res/drawable-night/sample_map.png
new file mode 100644
index 0000000000..7c58760c15
Binary files /dev/null and b/libraries/designsystem/src/main/res/drawable-night/sample_map.png differ
diff --git a/libraries/designsystem/src/main/res/drawable/sample_map.png b/libraries/designsystem/src/main/res/drawable/sample_map.png
new file mode 100644
index 0000000000..9bda2e15f2
Binary files /dev/null and b/libraries/designsystem/src/main/res/drawable/sample_map.png differ
diff --git a/libraries/designsystem/src/test/kotlin/io/element/android/libraries/designsystem/utils/snackbar/SnackbarDispatcherTest.kt b/libraries/designsystem/src/test/kotlin/io/element/android/libraries/designsystem/utils/snackbar/SnackbarDispatcherTest.kt
index 91c32b1da0..fe17aa0b6c 100644
--- a/libraries/designsystem/src/test/kotlin/io/element/android/libraries/designsystem/utils/snackbar/SnackbarDispatcherTest.kt
+++ b/libraries/designsystem/src/test/kotlin/io/element/android/libraries/designsystem/utils/snackbar/SnackbarDispatcherTest.kt
@@ -54,6 +54,27 @@ class SnackbarDispatcherTest {
}
}
+ @Test
+ fun `a host that subscribes after a message is posted still receives it`() = runTest {
+ val snackbarDispatcher = SnackbarDispatcher()
+ val message = SnackbarMessage(0)
+ // A first host is already collecting (mirrors a background screen still subscribed).
+ snackbarDispatcher.snackbarMessage.test {
+ assertThat(awaitItem()).isNull()
+ snackbarDispatcher.post(message)
+ assertThat(awaitItem()).isEqualTo(message)
+
+ // A second host subscribes only afterwards — e.g. the screen a flow pops back to once the
+ // message has already been posted. It must observe the current message rather than being
+ // starved waiting for the next post (regression: an earlier mutex-gated implementation
+ // delivered each post to a single parked collector, dropping the snackbar for any host
+ // that subscribed later).
+ snackbarDispatcher.snackbarMessage.test {
+ assertThat(awaitItem()).isEqualTo(message)
+ }
+ }
+ }
+
@Test
fun `given 2 message emissions, the next message is displayed only after a call to clear`() = runTest {
val snackbarDispatcher = SnackbarDispatcher()
diff --git a/libraries/eventformatter/impl/src/main/kotlin/io/element/android/libraries/eventformatter/impl/DefaultPinnedMessagesBannerFormatter.kt b/libraries/eventformatter/impl/src/main/kotlin/io/element/android/libraries/eventformatter/impl/DefaultPinnedMessagesBannerFormatter.kt
index c8afba3603..1311c524e4 100644
--- a/libraries/eventformatter/impl/src/main/kotlin/io/element/android/libraries/eventformatter/impl/DefaultPinnedMessagesBannerFormatter.kt
+++ b/libraries/eventformatter/impl/src/main/kotlin/io/element/android/libraries/eventformatter/impl/DefaultPinnedMessagesBannerFormatter.kt
@@ -18,6 +18,7 @@ import io.element.android.libraries.matrix.api.timeline.item.event.AudioMessageT
import io.element.android.libraries.matrix.api.timeline.item.event.EmoteMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.EventTimelineItem
import io.element.android.libraries.matrix.api.timeline.item.event.FileMessageType
+import io.element.android.libraries.matrix.api.timeline.item.event.GalleryMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.ImageMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.LocationMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.MessageContent
@@ -104,6 +105,9 @@ class DefaultPinnedMessagesBannerFormatter(
is OtherMessageType -> {
messageType.body
}
+ is GalleryMessageType -> {
+ messageType.body.prefixWith(CommonStrings.common_gallery)
+ }
is NoticeMessageType -> {
messageType.body
}
diff --git a/libraries/eventformatter/impl/src/main/kotlin/io/element/android/libraries/eventformatter/impl/DefaultRoomLatestEventFormatter.kt b/libraries/eventformatter/impl/src/main/kotlin/io/element/android/libraries/eventformatter/impl/DefaultRoomLatestEventFormatter.kt
index e8a462da16..1c5cd72977 100644
--- a/libraries/eventformatter/impl/src/main/kotlin/io/element/android/libraries/eventformatter/impl/DefaultRoomLatestEventFormatter.kt
+++ b/libraries/eventformatter/impl/src/main/kotlin/io/element/android/libraries/eventformatter/impl/DefaultRoomLatestEventFormatter.kt
@@ -23,6 +23,7 @@ import io.element.android.libraries.matrix.api.timeline.item.event.EventContent
import io.element.android.libraries.matrix.api.timeline.item.event.FailedToParseMessageLikeContent
import io.element.android.libraries.matrix.api.timeline.item.event.FailedToParseStateContent
import io.element.android.libraries.matrix.api.timeline.item.event.FileMessageType
+import io.element.android.libraries.matrix.api.timeline.item.event.GalleryMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.ImageMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.LegacyCallInviteContent
import io.element.android.libraries.matrix.api.timeline.item.event.LiveLocationContent
@@ -167,6 +168,9 @@ class DefaultRoomLatestEventFormatter(
is OtherMessageType -> {
messageType.body
}
+ is GalleryMessageType -> {
+ messageType.body.prefixWith(sp.getString(CommonStrings.common_gallery))
+ }
is NoticeMessageType -> {
messageType.body
}
diff --git a/libraries/eventformatter/impl/src/main/res/values-ru/translations.xml b/libraries/eventformatter/impl/src/main/res/values-ru/translations.xml
index e14813bb69..196e6ec3e1 100644
--- a/libraries/eventformatter/impl/src/main/res/values-ru/translations.xml
+++ b/libraries/eventformatter/impl/src/main/res/values-ru/translations.xml
@@ -27,7 +27,7 @@
"%1$s принял(а) приглашение"
"Вы приняли приглашение"
"Вы пригласили %1$s"
- "%1$s пригласил(а) вас"
+ "%1$s пригласил(а) Вас"
"%1$s присоединился(ась)"
"Вы присоединились к комнате"
"%1$s хочет присоединиться"
@@ -47,8 +47,8 @@
"Вы удалили название"
"%1$s не внес(ла) изменений"
"Вы не внесли изменений"
- "%1$s изменил(а) закрепленные сообщения"
- "Вы изменили закрепленные сообщения"
+ "%1$s изменил(а) закреплённые сообщения"
+ "Вы изменили закреплённые сообщения"
"%1$s закрепил(а) сообщение"
"Вы закрепили сообщение"
"%1$s открепил(а) сообщение"
diff --git a/libraries/eventformatter/impl/src/test/kotlin/io/element/android/libraries/eventformatter/impl/DefaultPinnedMessagesBannerFormatterTest.kt b/libraries/eventformatter/impl/src/test/kotlin/io/element/android/libraries/eventformatter/impl/DefaultPinnedMessagesBannerFormatterTest.kt
index 38334afad5..198da2bf7d 100644
--- a/libraries/eventformatter/impl/src/test/kotlin/io/element/android/libraries/eventformatter/impl/DefaultPinnedMessagesBannerFormatterTest.kt
+++ b/libraries/eventformatter/impl/src/test/kotlin/io/element/android/libraries/eventformatter/impl/DefaultPinnedMessagesBannerFormatterTest.kt
@@ -22,6 +22,7 @@ import io.element.android.libraries.matrix.api.timeline.item.event.EventTimeline
import io.element.android.libraries.matrix.api.timeline.item.event.FailedToParseMessageLikeContent
import io.element.android.libraries.matrix.api.timeline.item.event.FailedToParseStateContent
import io.element.android.libraries.matrix.api.timeline.item.event.FileMessageType
+import io.element.android.libraries.matrix.api.timeline.item.event.GalleryMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.ImageMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.LocationMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.MembershipChange
@@ -50,16 +51,14 @@ import io.element.android.libraries.matrix.test.timeline.anEventTimelineItem
import io.element.android.libraries.matrix.test.timeline.item.event.aRoomMembershipContent
import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.services.toolbox.impl.strings.AndroidStringProvider
+import io.element.android.tests.testutils.robolectric.RobolectricTest
import org.junit.Before
import org.junit.Test
-import org.junit.runner.RunWith
-import org.robolectric.RobolectricTestRunner
import org.robolectric.RuntimeEnvironment
import org.robolectric.annotation.Config
@Suppress("LargeClass")
-@RunWith(RobolectricTestRunner::class)
-class DefaultPinnedMessagesBannerFormatterTest {
+class DefaultPinnedMessagesBannerFormatterTest : RobolectricTest() {
private lateinit var context: Context
private lateinit var fakeMatrixClient: FakeMatrixClient
private lateinit var formatter: DefaultPinnedMessagesBannerFormatter
@@ -143,6 +142,7 @@ class DefaultPinnedMessagesBannerFormatterTest {
AudioMessageType(body, null, null, MediaSource("url"), null),
VoiceMessageType(body, null, null, MediaSource("url"), null, null),
ImageMessageType(body, null, null, MediaSource("url"), null),
+ GalleryMessageType(body, null, emptyList()),
StickerMessageType(body, null, null, MediaSource("url"), null),
FileMessageType(body, null, null, MediaSource("url"), null),
LocationMessageType(body, "geo:1,2", null, null),
@@ -165,6 +165,7 @@ class DefaultPinnedMessagesBannerFormatterTest {
is VideoMessageType,
is AudioMessageType,
is ImageMessageType,
+ is GalleryMessageType,
is StickerMessageType,
is FileMessageType,
is LocationMessageType -> AnnotatedString::class.java
@@ -183,6 +184,7 @@ class DefaultPinnedMessagesBannerFormatterTest {
is AudioMessageType -> "Audio: Shared body"
is VoiceMessageType -> "Voice message"
is ImageMessageType -> "Image: Shared body"
+ is GalleryMessageType -> "Gallery: Shared body"
is StickerMessageType -> "Sticker: Shared body"
is FileMessageType -> "File: Shared body"
is LocationMessageType -> "Shared location: Shared body"
diff --git a/libraries/eventformatter/impl/src/test/kotlin/io/element/android/libraries/eventformatter/impl/DefaultRoomLatestEventFormatterTest.kt b/libraries/eventformatter/impl/src/test/kotlin/io/element/android/libraries/eventformatter/impl/DefaultRoomLatestEventFormatterTest.kt
index 2e55fb0999..791a363f77 100644
--- a/libraries/eventformatter/impl/src/test/kotlin/io/element/android/libraries/eventformatter/impl/DefaultRoomLatestEventFormatterTest.kt
+++ b/libraries/eventformatter/impl/src/test/kotlin/io/element/android/libraries/eventformatter/impl/DefaultRoomLatestEventFormatterTest.kt
@@ -23,6 +23,7 @@ import io.element.android.libraries.matrix.api.timeline.item.event.EventContent
import io.element.android.libraries.matrix.api.timeline.item.event.FailedToParseMessageLikeContent
import io.element.android.libraries.matrix.api.timeline.item.event.FailedToParseStateContent
import io.element.android.libraries.matrix.api.timeline.item.event.FileMessageType
+import io.element.android.libraries.matrix.api.timeline.item.event.GalleryMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.ImageMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.LocationMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.MembershipChange
@@ -51,16 +52,14 @@ import io.element.android.libraries.matrix.test.timeline.aProfileDetails
import io.element.android.libraries.matrix.test.timeline.aStickerContent
import io.element.android.libraries.matrix.test.timeline.item.event.aRoomMembershipContent
import io.element.android.services.toolbox.impl.strings.AndroidStringProvider
+import io.element.android.tests.testutils.robolectric.RobolectricTest
import org.junit.Before
import org.junit.Test
-import org.junit.runner.RunWith
-import org.robolectric.RobolectricTestRunner
import org.robolectric.RuntimeEnvironment
import org.robolectric.annotation.Config
@Suppress("LargeClass")
-@RunWith(RobolectricTestRunner::class)
-class DefaultRoomLatestEventFormatterTest {
+class DefaultRoomLatestEventFormatterTest : RobolectricTest() {
private lateinit var context: Context
private lateinit var fakeMatrixClient: FakeMatrixClient
private lateinit var formatter: DefaultRoomLatestEventFormatter
@@ -197,6 +196,7 @@ class DefaultRoomLatestEventFormatterTest {
AudioMessageType(body, null, null, MediaSource("url"), null),
VoiceMessageType(body, null, null, MediaSource("url"), null, null),
ImageMessageType(body, null, null, MediaSource("url"), null),
+ GalleryMessageType(body, null, emptyList()),
StickerMessageType(body, null, null, MediaSource("url"), null),
FileMessageType(body, null, null, MediaSource("url"), null),
LocationMessageType(body, "geo:1,2", null, null),
@@ -229,6 +229,7 @@ class DefaultRoomLatestEventFormatterTest {
is AudioMessageType -> "Audio: Shared body"
is VoiceMessageType -> "Voice message"
is ImageMessageType -> "Image: Shared body"
+ is GalleryMessageType -> "Gallery: Shared body"
is StickerMessageType -> "Sticker: Shared body"
is FileMessageType -> "File: Shared body"
is LocationMessageType -> "Shared location"
@@ -249,6 +250,7 @@ class DefaultRoomLatestEventFormatterTest {
is TextMessageType -> false
is NoticeMessageType -> false
is OtherMessageType -> false
+ is GalleryMessageType -> true
}
if (shouldCreateAnnotatedString) {
assertWithMessage("$type doesn't produce an AnnotatedString")
@@ -266,6 +268,7 @@ class DefaultRoomLatestEventFormatterTest {
is AudioMessageType -> "$expectedPrefix: Audio: Shared body"
is VoiceMessageType -> "$expectedPrefix: Voice message"
is ImageMessageType -> "$expectedPrefix: Image: Shared body"
+ is GalleryMessageType -> "$expectedPrefix: Gallery: Shared body"
is StickerMessageType -> "$expectedPrefix: Sticker: Shared body"
is FileMessageType -> "$expectedPrefix: File: Shared body"
is LocationMessageType -> "$expectedPrefix: Shared location"
@@ -286,6 +289,7 @@ class DefaultRoomLatestEventFormatterTest {
is TextMessageType -> true
is NoticeMessageType -> true
is OtherMessageType -> true
+ is GalleryMessageType -> true
}
if (shouldCreateAnnotatedString) {
assertWithMessage("$type doesn't produce an AnnotatedString")
diff --git a/libraries/eventformatter/impl/src/test/kotlin/io/element/android/libraries/eventformatter/impl/RtcNotificationContentFormatterTest.kt b/libraries/eventformatter/impl/src/test/kotlin/io/element/android/libraries/eventformatter/impl/RtcNotificationContentFormatterTest.kt
index dca34db3cc..cd3a8df349 100644
--- a/libraries/eventformatter/impl/src/test/kotlin/io/element/android/libraries/eventformatter/impl/RtcNotificationContentFormatterTest.kt
+++ b/libraries/eventformatter/impl/src/test/kotlin/io/element/android/libraries/eventformatter/impl/RtcNotificationContentFormatterTest.kt
@@ -15,17 +15,14 @@ import io.element.android.libraries.matrix.test.A_USER_ID_2
import io.element.android.libraries.matrix.test.A_USER_ID_3
import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.services.toolbox.impl.strings.AndroidStringProvider
+import io.element.android.tests.testutils.robolectric.RobolectricTest
import org.junit.Before
import org.junit.Test
-import org.junit.runner.RunWith
-import org.robolectric.RobolectricTestRunner
import org.robolectric.RuntimeEnvironment
import org.robolectric.annotation.Config
-import kotlin.toString
@Suppress("LargeClass")
-@RunWith(RobolectricTestRunner::class)
-class RtcNotificationContentFormatterTest {
+class RtcNotificationContentFormatterTest : RobolectricTest() {
private lateinit var context: Context
private lateinit var fakeMatrixClient: FakeMatrixClient
private lateinit var formatter: RtcNotificationContentFormatter
diff --git a/libraries/featureflag/api/src/main/kotlin/io/element/android/libraries/featureflag/api/FeatureFlags.kt b/libraries/featureflag/api/src/main/kotlin/io/element/android/libraries/featureflag/api/FeatureFlags.kt
index 097df99800..29c7be3a3d 100644
--- a/libraries/featureflag/api/src/main/kotlin/io/element/android/libraries/featureflag/api/FeatureFlags.kt
+++ b/libraries/featureflag/api/src/main/kotlin/io/element/android/libraries/featureflag/api/FeatureFlags.kt
@@ -108,10 +108,11 @@ enum class FeatureFlags(
defaultValue = { true },
isFinished = false,
),
- FloatingDateBadge(
- key = "feature.floating_date_badge",
- title = "Display sticky date headers in the timeline",
- description = "When scrolling, a sticky date badge will be displayed so you can easily know on which date the messages you're seeing were sent.",
+ JumpToUnread(
+ key = "feature.jump_to_unread",
+ title = "Jump to unread messages",
+ description = "Show a button to jump to the read marker, plus a count badge on the scroll-to-bottom button " +
+ "when new messages arrive while scrolled away.",
defaultValue = { false },
isFinished = false,
),
@@ -137,4 +138,18 @@ enum class FeatureFlags(
defaultValue = { false },
isFinished = false,
),
+ UnreadIndicatorCount(
+ key = "feature.unread_indicator_count",
+ title = "Unread indicator count",
+ description = "Show the number of unread messages on the unread indicator in the room list.",
+ defaultValue = { false },
+ isFinished = false,
+ ),
+ SendGalleryMessages(
+ key = "feature.send_gallery_messages",
+ title = "Send gallery messages",
+ description = "Allow sending multiple media items in a single message.",
+ defaultValue = { false },
+ isFinished = false,
+ ),
}
diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/MatrixClient.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/MatrixClient.kt
index 836459f8c7..6546e4c41a 100644
--- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/MatrixClient.kt
+++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/MatrixClient.kt
@@ -27,6 +27,7 @@ import io.element.android.libraries.matrix.api.media.MediaPreviewService
import io.element.android.libraries.matrix.api.notification.NotificationService
import io.element.android.libraries.matrix.api.notificationsettings.NotificationSettingsService
import io.element.android.libraries.matrix.api.oauth.AccountManagementAction
+import io.element.android.libraries.matrix.api.paths.SessionPaths
import io.element.android.libraries.matrix.api.pusher.PushersService
import io.element.android.libraries.matrix.api.room.BaseRoom
import io.element.android.libraries.matrix.api.room.JoinedRoom
@@ -53,6 +54,7 @@ import java.util.Optional
interface MatrixClient {
val sessionId: SessionId
val deviceId: DeviceId
+ val sessionPaths: SessionPaths
val userProfile: StateFlow
val roomListService: RoomListService
val spaceService: SpaceService
@@ -201,6 +203,12 @@ interface MatrixClient {
*/
suspend fun markRoomAsFullyRead(roomId: RoomId, eventId: EventId): Result
+ /**
+ * Mark all joined rooms as read by sending public, private and fully-read receipts
+ * on each room's latest event. Per-room errors are logged and skipped by the SDK.
+ */
+ suspend fun markAllRoomsAsRead(): Result
+
/**
* Check if linking a new device using QrCode is supported by the server.
*/
diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/analytics/GetDatabaseSizesUseCase.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/analytics/GetDatabaseSizesUseCase.kt
index ab11374116..fde02fb2c6 100644
--- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/analytics/GetDatabaseSizesUseCase.kt
+++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/analytics/GetDatabaseSizesUseCase.kt
@@ -10,5 +10,5 @@ package io.element.android.libraries.matrix.api.analytics
import io.element.android.libraries.matrix.api.core.SessionId
fun interface GetDatabaseSizesUseCase {
- suspend operator fun invoke(sessionId: SessionId): Result
+ operator fun invoke(sessionId: SessionId): Result
}
diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/encryption/EncryptionService.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/encryption/EncryptionService.kt
index 333cfb709b..f82cdc1969 100644
--- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/encryption/EncryptionService.kt
+++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/encryption/EncryptionService.kt
@@ -24,9 +24,15 @@ interface EncryptionService {
suspend fun enableBackups(): Result
/**
- * Enable recovery. Observe enableProgressStateFlow to get progress and recovery key.
+ * Enable recovery and return the SDK-generated recovery key on success.
+ * Observe [enableRecoveryProgressStateFlow] for in-progress UI updates.
+ *
+ * @param waitForBackupsToUpload when true, suspends until existing room keys finish uploading.
+ * @param passphrase optional user-supplied passphrase. When set, the SDK derives the
+ * secret-storage key from it instead of a random base58 key; the passphrase can later be
+ * passed to [recover], and the returned base58 key should not be surfaced to the user.
*/
- suspend fun enableRecovery(waitForBackupsToUpload: Boolean): Result
+ suspend fun enableRecovery(waitForBackupsToUpload: Boolean, passphrase: String? = null): Result
/**
* Change the recovery and return the new recovery key.
diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/exception/ClientException.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/exception/ClientException.kt
index 52b1577bf0..565dc59614 100644
--- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/exception/ClientException.kt
+++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/exception/ClientException.kt
@@ -15,6 +15,7 @@ sealed class ClientException(message: String, val details: String?, cause: Throw
details = details,
cause = cause
)
+ class ContentScanner(message: String, val reason: ContentScannerErrorReason) : ClientException(message, null, null)
class Other(message: String, cause: Throwable? = null) : ClientException(message, null, cause)
}
diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/exception/ContentScannerErrorReason.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/exception/ContentScannerErrorReason.kt
new file mode 100644
index 0000000000..5c32b3989b
--- /dev/null
+++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/exception/ContentScannerErrorReason.kt
@@ -0,0 +1,28 @@
+/*
+ * Copyright (c) 2026 Element Creations Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
+ * Please see LICENSE files in the repository root for full details.
+ */
+
+package io.element.android.libraries.matrix.api.exception
+
+enum class ContentScannerErrorReason {
+ MCS_MALFORMED_JSON,
+ MCS_MEDIA_FAILED_TO_DECRYPT,
+ M_MISSING_TOKEN,
+ M_UNKNOWN_TOKEN,
+ M_NOT_FOUND,
+ MCS_MEDIA_NOT_CLEAN,
+ MCS_MIME_TYPE_FORBIDDEN,
+ MCS_BAD_DECRYPTION,
+ M_UNKNOWN,
+ MCS_MEDIA_REQUEST_FAILED;
+
+ companion object;
+
+ fun isDangerous(): Boolean = when (this) {
+ MCS_MEDIA_NOT_CLEAN, MCS_MIME_TYPE_FORBIDDEN -> true
+ else -> false
+ }
+}
diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/linknewdevice/LinkMobileHandler.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/linknewdevice/LinkMobileHandler.kt
index 1947729c7f..eb2d49b5b0 100644
--- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/linknewdevice/LinkMobileHandler.kt
+++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/linknewdevice/LinkMobileHandler.kt
@@ -16,6 +16,9 @@ interface LinkMobileHandler {
sealed interface LinkMobileStep {
data object Uninitialized : LinkMobileStep
+
+ // Internal application step, for the UI
+ data object CreatingQrCode : LinkMobileStep
data object Starting : LinkMobileStep
data class QrReady(val data: String) : LinkMobileStep
data object QrRotating : LinkMobileStep
diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/media/GalleryItemInfo.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/media/GalleryItemInfo.kt
new file mode 100644
index 0000000000..484e701dbc
--- /dev/null
+++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/media/GalleryItemInfo.kt
@@ -0,0 +1,36 @@
+/*
+ * Copyright (c) 2026 Element Creations Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
+ * Please see LICENSE files in the repository root for full details.
+ */
+
+package io.element.android.libraries.matrix.api.media
+
+import java.io.File
+
+sealed interface GalleryItemInfo {
+ val file: File
+
+ data class Image(
+ override val file: File,
+ val imageInfo: ImageInfo,
+ val thumbnailFile: File?,
+ ) : GalleryItemInfo
+
+ data class Video(
+ override val file: File,
+ val videoInfo: VideoInfo,
+ val thumbnailFile: File?,
+ ) : GalleryItemInfo
+
+ data class Audio(
+ override val file: File,
+ val audioInfo: AudioInfo,
+ ) : GalleryItemInfo
+
+ data class MediaFile(
+ override val file: File,
+ val fileInfo: FileInfo,
+ ) : GalleryItemInfo
+}
diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/paths/SessionPaths.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/paths/SessionPaths.kt
new file mode 100644
index 0000000000..d481316f9d
--- /dev/null
+++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/paths/SessionPaths.kt
@@ -0,0 +1,20 @@
+/*
+ * Copyright (c) 2026 Element Creations Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
+ * Please see LICENSE files in the repository root for full details.
+ */
+
+package io.element.android.libraries.matrix.api.paths
+
+import java.io.File
+
+data class SessionPaths(
+ val fileDirectory: File,
+ val cacheDirectory: File,
+) {
+ fun deleteRecursively() {
+ fileDirectory.deleteRecursively()
+ cacheDirectory.deleteRecursively()
+ }
+}
diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/BaseRoom.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/BaseRoom.kt
index f7f4924d5a..d49343f998 100644
--- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/BaseRoom.kt
+++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/BaseRoom.kt
@@ -157,11 +157,6 @@ interface BaseRoom : Closeable {
*/
suspend fun setUnreadFlag(isUnread: Boolean): Result
- /**
- * Clear the event cache storage for the current room.
- */
- suspend fun clearEventCacheStorage(): Result
-
/**
* Get the permalink for the room.
*/
diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/JoinedRoom.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/JoinedRoom.kt
index f3fefab9a8..6d8325427b 100644
--- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/JoinedRoom.kt
+++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/JoinedRoom.kt
@@ -212,4 +212,11 @@ interface JoinedRoom : BaseRoom {
* @return Result indicating success or failure.
*/
suspend fun sendLiveLocation(geoUri: String): Result
+
+ /**
+ * Sets the display name of the current user within this room.
+ * This is different from the global setDisplayName which updates
+ * the user's display name across all of their rooms.
+ */
+ suspend fun setOwnMemberDisplayName(displayName: String): Result
}
diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/StateEventType.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/StateEventType.kt
index 705dd25122..7bfc5dc6bb 100644
--- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/StateEventType.kt
+++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/StateEventType.kt
@@ -34,6 +34,7 @@ sealed interface StateEventType {
data object MemberHints : StateEventType
data object RoomImagePack : StateEventType
data object RoomLanguage : StateEventType
+ data object RoomPolicy : StateEventType
data class Custom(val type: String) : StateEventType
}
diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/Timeline.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/Timeline.kt
index fe73230dce..8cb933205b 100644
--- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/Timeline.kt
+++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/Timeline.kt
@@ -16,6 +16,7 @@ import io.element.android.libraries.matrix.api.core.ThreadId
import io.element.android.libraries.matrix.api.core.TransactionId
import io.element.android.libraries.matrix.api.media.AudioInfo
import io.element.android.libraries.matrix.api.media.FileInfo
+import io.element.android.libraries.matrix.api.media.GalleryItemInfo
import io.element.android.libraries.matrix.api.media.ImageInfo
import io.element.android.libraries.matrix.api.media.MediaUploadHandler
import io.element.android.libraries.matrix.api.media.VideoInfo
@@ -157,6 +158,13 @@ interface Timeline : AutoCloseable {
inReplyToEventId: EventId?,
): Result
+ suspend fun sendGallery(
+ items: List,
+ caption: String?,
+ formattedCaption: String?,
+ inReplyToEventId: EventId?,
+ ): Result
+
suspend fun redactEvent(eventOrTransactionId: EventOrTransactionId, reason: String?): Result
suspend fun toggleReaction(emoji: String, eventOrTransactionId: EventOrTransactionId): Result
@@ -216,6 +224,13 @@ interface Timeline : AutoCloseable {
suspend fun loadReplyDetails(eventId: EventId): InReplyTo
+ /**
+ * Returns true if [eventId] is currently loaded in this timeline's window, even if the display
+ * layer does not render it (e.g. filtered or state events). This distinguishes "in the window
+ * but not displayed" from "genuinely outside the loaded window".
+ */
+ suspend fun isEventLoaded(eventId: EventId): Boolean
+
/**
* Adds a new pinned event by sending an updated `m.room.pinned_events`
* event containing the new event id.
diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/item/event/MessageType.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/item/event/MessageType.kt
index 0cc5ca0ff7..fdc538ec44 100644
--- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/item/event/MessageType.kt
+++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/item/event/MessageType.kt
@@ -102,6 +102,21 @@ data class TextMessageType(
val formatted: FormattedBody?
) : MessageType
+data class GalleryMessageType(
+ val body: String,
+ val formatted: FormattedBody?,
+ val items: List,
+) : MessageType
+
+@Immutable
+sealed interface GalleryItemType {
+ data class Image(val content: ImageMessageType) : GalleryItemType
+ data class Audio(val content: AudioMessageType) : GalleryItemType
+ data class Video(val content: VideoMessageType) : GalleryItemType
+ data class File(val content: FileMessageType) : GalleryItemType
+ data class Other(val itemType: String, val body: String) : GalleryItemType
+}
+
data class OtherMessageType(
val msgType: String,
val body: String,
diff --git a/libraries/matrix/impl/build.gradle.kts b/libraries/matrix/impl/build.gradle.kts
index 0a95147f21..2499b720e3 100644
--- a/libraries/matrix/impl/build.gradle.kts
+++ b/libraries/matrix/impl/build.gradle.kts
@@ -44,7 +44,7 @@ dependencies {
implementation(projects.services.toolbox.api)
api(projects.libraries.matrix.api)
implementation(projects.libraries.core)
- implementation("net.java.dev.jna:jna:5.18.1@aar")
+ implementation(variantOf(libs.jna) { artifactType("aar") })
implementation(libs.androidx.datastore.preferences)
implementation(libs.serialization.json)
implementation(libs.kotlinx.collections.immutable)
diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt
index bec20aedf2..2cfbbbdc73 100644
--- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt
+++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt
@@ -32,6 +32,7 @@ import io.element.android.libraries.matrix.api.linknewdevice.LinkDesktopHandler
import io.element.android.libraries.matrix.api.linknewdevice.LinkMobileHandler
import io.element.android.libraries.matrix.api.media.MatrixMediaLoader
import io.element.android.libraries.matrix.api.oauth.AccountManagementAction
+import io.element.android.libraries.matrix.api.paths.SessionPaths
import io.element.android.libraries.matrix.api.room.BaseRoom
import io.element.android.libraries.matrix.api.room.CurrentUserMembership
import io.element.android.libraries.matrix.api.room.JoinedRoom
@@ -81,7 +82,6 @@ import io.element.android.libraries.matrix.impl.spaces.RustSpaceService
import io.element.android.libraries.matrix.impl.sync.RustSyncService
import io.element.android.libraries.matrix.impl.sync.map
import io.element.android.libraries.matrix.impl.usersearch.UserSearchResultMapper
-import io.element.android.libraries.matrix.impl.util.SessionPathsProvider
import io.element.android.libraries.matrix.impl.util.cancelAndDestroy
import io.element.android.libraries.matrix.impl.util.mxCallbackFlow
import io.element.android.libraries.matrix.impl.verification.RustSessionVerificationService
@@ -137,6 +137,7 @@ import org.matrix.rustcomponents.sdk.RoomPreset as RustRoomPreset
import org.matrix.rustcomponents.sdk.SyncService as ClientSyncService
class RustMatrixClient(
+ override val sessionPaths: SessionPaths,
private val innerClient: Client,
private val sessionStore: SessionStore,
private val sessionDelegate: RustClientSessionDelegate,
@@ -187,8 +188,6 @@ class RustMatrixClient(
sessionDispatcher = sessionDispatcher,
)
- private val sessionPathsProvider = SessionPathsProvider(sessionStore)
-
private val roomSyncSubscriber: RoomSyncSubscriber = RoomSyncSubscriber(innerRoomListService, dispatchers)
override val roomListService: RoomListService = RustRoomListService(
@@ -801,6 +800,12 @@ class RustMatrixClient(
}
}
+ override suspend fun markAllRoomsAsRead(): Result = withContext(sessionDispatcher) {
+ runCatchingExceptions {
+ innerClient.markAllRoomsAsRead()
+ }
+ }
+
override suspend fun performDatabaseVacuum(): Result = withContext(sessionDispatcher) {
runCatchingExceptions {
Timber.d("Performing database vacuuming for session $sessionId...")
@@ -824,17 +829,16 @@ class RustMatrixClient(
private suspend fun getCacheSize(
includeCryptoDb: Boolean = false,
): Long = withContext(sessionDispatcher) {
- val sessionDirectory = sessionPathsProvider.provides(sessionId) ?: return@withContext 0L
- val cacheSize = sessionDirectory.cacheDirectory.getSizeOfFiles()
+ val cacheSize = sessionPaths.cacheDirectory.getSizeOfFiles()
if (includeCryptoDb) {
- cacheSize + sessionDirectory.fileDirectory.getSizeOfFiles()
+ cacheSize + sessionPaths.fileDirectory.getSizeOfFiles()
} else {
cacheSize + listOf(
"matrix-sdk-state.sqlite3",
"matrix-sdk-state.sqlite3-shm",
"matrix-sdk-state.sqlite3-wal",
).map { fileName ->
- File(sessionDirectory.fileDirectory, fileName)
+ File(sessionPaths.fileDirectory, fileName)
}.sumOf { file ->
file.length()
}
@@ -843,7 +847,7 @@ class RustMatrixClient(
private suspend fun deleteSessionDirectory() = withContext(sessionDispatcher) {
// Delete all the files for this session
- sessionPathsProvider.provides(sessionId)?.deleteRecursively()
+ sessionPaths.deleteRecursively()
}
private fun scheduleDatabaseVacuum() {
diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClientFactory.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClientFactory.kt
index 4a91462399..fae99b75c8 100644
--- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClientFactory.kt
+++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClientFactory.kt
@@ -17,9 +17,8 @@ import io.element.android.libraries.di.CacheDirectory
import io.element.android.libraries.di.annotations.AppCoroutineScope
import io.element.android.libraries.featureflag.api.FeatureFlagService
import io.element.android.libraries.featureflag.api.FeatureFlags
+import io.element.android.libraries.matrix.api.paths.SessionPaths
import io.element.android.libraries.matrix.impl.analytics.UtdTracker
-import io.element.android.libraries.matrix.impl.certificates.UserCertificatesProvider
-import io.element.android.libraries.matrix.impl.paths.SessionPaths
import io.element.android.libraries.matrix.impl.paths.getSessionPaths
import io.element.android.libraries.matrix.impl.proxy.ProxyProvider
import io.element.android.libraries.matrix.impl.room.TimelineEventFilterFactory
@@ -60,7 +59,6 @@ class RustMatrixClientFactory(
private val sessionStore: SessionStore,
private val userAgentProvider: UserAgentProvider,
private val proxyProvider: ProxyProvider,
- private val userCertificatesProvider: UserCertificatesProvider,
private val clock: SystemClock,
private val analyticsService: AnalyticsService,
private val featureFlagService: FeatureFlagService,
@@ -102,10 +100,10 @@ class RustMatrixClientFactory(
client.restoreSession(sessionData.toSession())
- create(client)
+ create(client, sessionData)
}
- suspend fun create(client: Client): RustMatrixClient {
+ suspend fun create(client: Client, sessionData: SessionData): RustMatrixClient {
val (anonymizedAccessToken, anonymizedRefreshToken) = client.session().anonymizedTokens()
// Must be called before creating the sync service, timelines etc.
@@ -121,6 +119,7 @@ class RustMatrixClientFactory(
.finish()
return RustMatrixClient(
+ sessionPaths = sessionData.getSessionPaths(),
innerClient = client,
sessionStore = sessionStore,
appCoroutineScope = appCoroutineScope,
@@ -151,7 +150,6 @@ class RustMatrixClientFactory(
}
.setSessionDelegate(sessionDelegate)
.userAgent(userAgentProvider.provide())
- .addRootCertificates(userCertificatesProvider.provides())
.autoEnableBackups(true)
.autoEnableCrossSigning(true)
.roomKeyRecipientStrategy(
diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/analytics/DefaultGetDatabaseSizesUseCase.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/analytics/DefaultGetDatabaseSizesUseCase.kt
index 8973483132..8dba525788 100644
--- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/analytics/DefaultGetDatabaseSizesUseCase.kt
+++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/analytics/DefaultGetDatabaseSizesUseCase.kt
@@ -1,5 +1,5 @@
/*
- * Copyright (c) 2025 Element Creations Ltd.
+ * Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
@@ -9,19 +9,53 @@ package io.element.android.libraries.matrix.impl.analytics
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesBinding
+import io.element.android.libraries.core.data.bytes
import io.element.android.libraries.matrix.api.MatrixClientProvider
import io.element.android.libraries.matrix.api.analytics.GetDatabaseSizesUseCase
import io.element.android.libraries.matrix.api.analytics.SdkStoreSizes
import io.element.android.libraries.matrix.api.core.SessionId
+import java.io.File
+import kotlin.math.max
+/**
+ * An implementation of [GetDatabaseSizesUseCase] that manually calculates the database sizes by checking the file system.
+ * This is not as accurate as the SDK's internal method, but is used as a fallback for Sentry's non-suspendable hook where
+ * calling the SDK's suspendable method blocks the main thread and causes ANRs.
+ */
@ContributesBinding(AppScope::class)
class DefaultGetDatabaseSizesUseCase(
private val clientProvider: Lazy,
) : GetDatabaseSizesUseCase {
- override suspend fun invoke(sessionId: SessionId): Result {
+ override fun invoke(sessionId: SessionId): Result {
val client = clientProvider.value.getOrNull(sessionId)
?: return Result.failure(IllegalArgumentException("No MatrixClient for session $sessionId"))
- return client.getDatabaseSizes()
+ val fileDir = client.sessionPaths.fileDirectory
+ val cacheDir = client.sessionPaths.cacheDirectory
+
+ val cryptoSize = getDatabaseSize(fileDir, "matrix-sdk-crypto")
+ val stateSize = getDatabaseSize(fileDir, "matrix-sdk-state")
+
+ val eventCacheSize = getDatabaseSize(cacheDir, "matrix-sdk-event-cache")
+ val mediaCacheSize = getDatabaseSize(cacheDir, "matrix-sdk-media")
+
+ return Result.success(SdkStoreSizes(
+ stateStore = stateSize.bytes,
+ cryptoStore = cryptoSize.bytes,
+ eventCacheStore = eventCacheSize.bytes,
+ mediaStore = mediaCacheSize.bytes,
+ ))
+ }
+
+ private fun getDatabaseSize(baseDirectory: File, databaseFileName: String): Long {
+ // Check the sizes of both the main database file and the -wal file, and return the max of the two:
+ // The -wal file can contain additional data not yet merged into the main file, becoming larger than it.
+ val databaseFile = File(baseDirectory, "$databaseFileName.sqlite3")
+ val walFile = File(baseDirectory, "$databaseFileName.sqlite3-wal")
+
+ val databaseSize = if (databaseFile.exists()) databaseFile.length() else 0L
+ val walSize = if (walFile.exists()) walFile.length() else 0L
+
+ return max(databaseSize, walSize)
}
}
diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/auth/RustHomeServerLoginCompatibilityChecker.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/auth/RustHomeServerLoginCompatibilityChecker.kt
index 3f8893138f..42cbd14cb3 100644
--- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/auth/RustHomeServerLoginCompatibilityChecker.kt
+++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/auth/RustHomeServerLoginCompatibilityChecker.kt
@@ -13,19 +13,16 @@ import dev.zacsweers.metro.ContributesBinding
import io.element.android.libraries.core.extensions.runCatchingExceptions
import io.element.android.libraries.matrix.api.auth.HomeServerLoginCompatibilityChecker
import io.element.android.libraries.matrix.impl.ClientBuilderProvider
-import io.element.android.libraries.matrix.impl.certificates.UserCertificatesProvider
import timber.log.Timber
@ContributesBinding(AppScope::class)
class RustHomeServerLoginCompatibilityChecker(
private val clientBuilderProvider: ClientBuilderProvider,
- private val userCertificatesProvider: UserCertificatesProvider,
) : HomeServerLoginCompatibilityChecker {
override suspend fun check(url: String): Result = runCatchingExceptions {
clientBuilderProvider.provide()
.inMemoryStore()
.serverNameOrHomeserverUrl(url)
- .addRootCertificates(userCertificatesProvider.provides())
.build()
.use {
it.homeserverLoginDetails()
diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/auth/RustMatrixAuthenticationService.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/auth/RustMatrixAuthenticationService.kt
index c44f7ffae7..639031334e 100644
--- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/auth/RustMatrixAuthenticationService.kt
+++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/auth/RustMatrixAuthenticationService.kt
@@ -29,6 +29,7 @@ import io.element.android.libraries.matrix.api.auth.qrlogin.MatrixQrCodeLoginDat
import io.element.android.libraries.matrix.api.auth.qrlogin.QrCodeLoginStep
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.core.UserId
+import io.element.android.libraries.matrix.api.paths.SessionPaths
import io.element.android.libraries.matrix.api.verification.SessionVerifiedStatus
import io.element.android.libraries.matrix.impl.ClientBuilderSlidingSync
import io.element.android.libraries.matrix.impl.RustMatrixClientFactory
@@ -38,7 +39,6 @@ import io.element.android.libraries.matrix.impl.auth.qrlogin.toStep
import io.element.android.libraries.matrix.impl.exception.mapClientException
import io.element.android.libraries.matrix.impl.keys.SecretGenerator
import io.element.android.libraries.matrix.impl.mapper.toSessionData
-import io.element.android.libraries.matrix.impl.paths.SessionPaths
import io.element.android.libraries.matrix.impl.paths.SessionPathsFactory
import io.element.android.libraries.matrix.impl.toSession
import io.element.android.libraries.sessionstorage.api.LoginType
@@ -160,7 +160,7 @@ class RustMatrixAuthenticationService(
passphrase = pendingKey.formattedAsString(),
sessionPaths = currentSessionPaths,
)
- val matrixClient = rustMatrixClientFactory.create(client)
+ val matrixClient = rustMatrixClientFactory.create(client, sessionData)
newMatrixClientObservers.forEach { it.invoke(matrixClient) }
sessionStore.addSession(sessionData)
@@ -235,7 +235,7 @@ class RustMatrixAuthenticationService(
// We restore the client using the just retrieved session data
client.restoreSession(sessionData.toSession())
- val matrixClient = rustMatrixClientFactory.create(client)
+ val matrixClient = rustMatrixClientFactory.create(client, sessionData)
// We wait for the verification state to be known
matrixClient.waitForKnownVerificationState()
@@ -325,7 +325,7 @@ class RustMatrixAuthenticationService(
passphrase = pendingKey.formattedAsString(),
sessionPaths = currentSessionPaths,
)
- val matrixClient = rustMatrixClientFactory.create(client)
+ val matrixClient = rustMatrixClientFactory.create(client, sessionData)
matrixClient.waitForKnownVerificationState()
newMatrixClientObservers.forEach { it.invoke(matrixClient) }
@@ -390,7 +390,7 @@ class RustMatrixAuthenticationService(
passphrase = pendingKey.formattedAsString(),
sessionPaths = emptySessionPaths,
)
- val matrixClient = rustMatrixClientFactory.create(client)
+ val matrixClient = rustMatrixClientFactory.create(client, sessionData)
newMatrixClientObservers.forEach { it.invoke(matrixClient) }
sessionStore.addSession(sessionData)
diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/certificates/DefaultUserCertificatesProvider.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/certificates/DefaultUserCertificatesProvider.kt
deleted file mode 100644
index 39be03ce5b..0000000000
--- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/certificates/DefaultUserCertificatesProvider.kt
+++ /dev/null
@@ -1,76 +0,0 @@
-/*
- * Copyright (c) 2026 Element Creations Ltd.
- *
- * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
- * Please see LICENSE files in the repository root for full details.
- */
-
-package io.element.android.libraries.matrix.impl.certificates
-
-import dev.zacsweers.metro.AppScope
-import dev.zacsweers.metro.ContributesBinding
-import timber.log.Timber
-import java.security.KeyStore
-import java.security.KeyStoreException
-
-@ContributesBinding(AppScope::class)
-class DefaultUserCertificatesProvider : UserCertificatesProvider {
- /**
- * Get additional user-installed certificates from the `AndroidCAStore` `Keystore`.
- *
- * The Rust HTTP client doesn't include user-installed certificates in its internal certificate
- * store. This means that whatever the user installs will be ignored.
- *
- * While most users don't need user-installed certificates some special deployments or debugging
- * setups using a proxy might want to use them.
- *
- * @return A list of byte arrays where each byte array is a single user-installed certificate
- * in encoded form.
- */
- override fun provides(): List {
- // At least for API 34 the `AndroidCAStore` `Keystore` type contained user certificates as well.
- // I have not found this to be documented anywhere.
- val keyStore: KeyStore = try {
- KeyStore.getInstance("AndroidCAStore")
- } catch (e: KeyStoreException) {
- Timber.w(e, "Failed to get AndroidCAStore keystore")
- return emptyList()
- }
- val aliases = try {
- keyStore.load(null)
- keyStore.aliases()
- } catch (e: Exception) {
- Timber.w(e, "Failed to load and get aliases AndroidCAStore keystore")
- return emptyList()
- }
- return aliases.toList()
- .filter { alias ->
- // The certificate alias always contains the prefix `system` or
- // `user` and the MD5 subject hash separated by a colon.
- //
- // The subject hash can be calculated using openssl as such:
- // openssl x509 -subject_hash_old -noout -in mycert.cer
- //
- // Again, I have not found this to be documented somewhere.
- alias.startsWith("user")
- }
- .mapNotNull { alias ->
- try {
- keyStore.getEntry(alias, null)
- } catch (e: Exception) {
- Timber.w(e, "Failed to get entry for alias $alias")
- null
- }
- }
- .filterIsInstance()
- .map { trustedCertificateEntry ->
- trustedCertificateEntry.trustedCertificate.encoded
- }
- .also {
- // Let's at least log the number of user-installed certificates we found,
- // since the alias isn't particularly useful nor does the issuer seem to
- // be easily available.
- Timber.i("Found ${it.size} additional user-provided certificates.")
- }
- }
-}
diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/encryption/RustEncryptionService.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/encryption/RustEncryptionService.kt
index f67b7cb7e1..74f235f9f8 100644
--- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/encryption/RustEncryptionService.kt
+++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/encryption/RustEncryptionService.kt
@@ -131,19 +131,28 @@ class RustEncryptionService(
override suspend fun enableRecovery(
waitForBackupsToUpload: Boolean,
- ): Result = withContext(dispatchers.io) {
+ passphrase: String?,
+ ): Result = withContext(dispatchers.io) {
runCatchingExceptions {
- service.enableRecovery(
+ // The key arrives as the suspend return value (like resetRecoveryKey), avoiding a
+ // flow/return-value race; the listener only feeds sub-progress.
+ enableRecoveryProgressStateFlow.value = EnableRecoveryProgress.Starting
+ val key = service.enableRecovery(
waitForBackupsToUpload = waitForBackupsToUpload,
progressListener = object : EnableRecoveryProgressListener {
override fun onUpdate(status: RustEnableRecoveryProgress) {
enableRecoveryProgressStateFlow.value = enableRecoveryProgressMapper.map(status)
}
},
- passphrase = null,
+ passphrase = passphrase,
)
- // enableRecovery returns the encryption key, but we read it from the state flow
- .let { }
+ // Pin Done explicitly so observers get a coherent terminal value. For the passphrase
+ // path the user never sees the SDK base58 key, so keep it out of this session-scoped
+ // (in-memory) flow entirely; the caller still receives it as the return value and is
+ // responsible for scrubbing it. The auto-generated path must retain the key here since
+ // that is how it is surfaced to the user.
+ enableRecoveryProgressStateFlow.value = EnableRecoveryProgress.Done(if (passphrase != null) "" else key)
+ key
}.mapFailure {
it.mapRecoveryException()
}
diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/exception/ClientException.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/exception/ClientException.kt
index eefa6d4986..27d799f67b 100644
--- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/exception/ClientException.kt
+++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/exception/ClientException.kt
@@ -9,6 +9,7 @@
package io.element.android.libraries.matrix.impl.exception
import io.element.android.libraries.matrix.api.exception.ClientException
+import io.element.android.libraries.matrix.api.exception.ContentScannerErrorReason
import org.matrix.rustcomponents.sdk.ClientException as RustClientException
fun Throwable.mapClientException(): ClientException {
@@ -23,6 +24,10 @@ fun Throwable.mapClientException(): ClientException {
details = details,
cause = this,
)
+ is RustClientException.ContentScanner -> ClientException.ContentScanner(
+ message = message,
+ reason = ContentScannerErrorReason.fromRust(reason),
+ )
}
}
else -> ClientException.Other(message ?: "Unknown error", this)
diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/exception/ContentScannerErrorReason.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/exception/ContentScannerErrorReason.kt
new file mode 100644
index 0000000000..085a570278
--- /dev/null
+++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/exception/ContentScannerErrorReason.kt
@@ -0,0 +1,34 @@
+/*
+ * Copyright (c) 2026 Element Creations Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
+ * Please see LICENSE files in the repository root for full details.
+ */
+
+package io.element.android.libraries.matrix.impl.exception
+
+import io.element.android.libraries.matrix.api.exception.ContentScannerErrorReason
+import uniffi.matrix_sdk_contentscanner.ErrorReason
+import uniffi.matrix_sdk_contentscanner.ErrorReason.MCS_BAD_DECRYPTION
+import uniffi.matrix_sdk_contentscanner.ErrorReason.MCS_MALFORMED_JSON
+import uniffi.matrix_sdk_contentscanner.ErrorReason.MCS_MEDIA_FAILED_TO_DECRYPT
+import uniffi.matrix_sdk_contentscanner.ErrorReason.MCS_MEDIA_NOT_CLEAN
+import uniffi.matrix_sdk_contentscanner.ErrorReason.MCS_MEDIA_REQUEST_FAILED
+import uniffi.matrix_sdk_contentscanner.ErrorReason.MCS_MIME_TYPE_FORBIDDEN
+import uniffi.matrix_sdk_contentscanner.ErrorReason.M_MISSING_TOKEN
+import uniffi.matrix_sdk_contentscanner.ErrorReason.M_NOT_FOUND
+import uniffi.matrix_sdk_contentscanner.ErrorReason.M_UNKNOWN
+import uniffi.matrix_sdk_contentscanner.ErrorReason.M_UNKNOWN_TOKEN
+
+fun ContentScannerErrorReason.Companion.fromRust(reason: ErrorReason) = when (reason) {
+ M_UNKNOWN -> ContentScannerErrorReason.M_UNKNOWN
+ M_MISSING_TOKEN -> ContentScannerErrorReason.M_MISSING_TOKEN
+ M_UNKNOWN_TOKEN -> ContentScannerErrorReason.M_UNKNOWN_TOKEN
+ MCS_MALFORMED_JSON -> ContentScannerErrorReason.MCS_MALFORMED_JSON
+ MCS_MEDIA_FAILED_TO_DECRYPT -> ContentScannerErrorReason.MCS_MEDIA_FAILED_TO_DECRYPT
+ M_NOT_FOUND -> ContentScannerErrorReason.M_NOT_FOUND
+ MCS_MEDIA_NOT_CLEAN -> ContentScannerErrorReason.MCS_MEDIA_NOT_CLEAN
+ MCS_MIME_TYPE_FORBIDDEN -> ContentScannerErrorReason.MCS_MIME_TYPE_FORBIDDEN
+ MCS_BAD_DECRYPTION -> ContentScannerErrorReason.MCS_BAD_DECRYPTION
+ MCS_MEDIA_REQUEST_FAILED -> ContentScannerErrorReason.MCS_MEDIA_REQUEST_FAILED
+}
diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/linknewdevice/RustLinkMobileHandler.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/linknewdevice/RustLinkMobileHandler.kt
index cb387a9d21..a44a8d5943 100644
--- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/linknewdevice/RustLinkMobileHandler.kt
+++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/linknewdevice/RustLinkMobileHandler.kt
@@ -51,6 +51,10 @@ class RustLinkMobileHandler(
)
// We emit Done in case the progress listener was deallocated before generate() sent the Done
_linkMobileStep.emit(LinkMobileStep.Done)
+ } catch (e: HumanQrGrantLoginException.Expired) {
+ // Note: the SDK does not return this error, but is returning `.NotFound` instead when the QR code expires. We catch both just in case.
+ Timber.tag(tag.value).w(e, "QR code has expired")
+ _linkMobileStep.emit(LinkMobileStep.QrRotating)
} catch (e: HumanQrGrantLoginException.NotFound) {
Timber.tag(tag.value).w(e, "Error during QR login grant")
// Catch timeout here?
diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/mapper/Session.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/mapper/Session.kt
index dfda37d7e1..b5964f1a2e 100644
--- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/mapper/Session.kt
+++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/mapper/Session.kt
@@ -9,7 +9,7 @@
package io.element.android.libraries.matrix.impl.mapper
import io.element.android.libraries.matrix.api.auth.external.ExternalSession
-import io.element.android.libraries.matrix.impl.paths.SessionPaths
+import io.element.android.libraries.matrix.api.paths.SessionPaths
import io.element.android.libraries.sessionstorage.api.LoginType
import io.element.android.libraries.sessionstorage.api.SessionData
import org.matrix.rustcomponents.sdk.Session
diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/media/GalleryItemInfoMapper.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/media/GalleryItemInfoMapper.kt
new file mode 100644
index 0000000000..6e90ea3d62
--- /dev/null
+++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/media/GalleryItemInfoMapper.kt
@@ -0,0 +1,49 @@
+/*
+ * Copyright (c) 2026 Element Creations Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
+ * Please see LICENSE files in the repository root for full details.
+ */
+
+package io.element.android.libraries.matrix.impl.media
+
+import io.element.android.libraries.matrix.api.media.GalleryItemInfo
+import org.matrix.rustcomponents.sdk.GalleryItemInfo as RustGalleryItemInfo
+import org.matrix.rustcomponents.sdk.UploadSource as RustUploadSource
+
+fun GalleryItemInfo.map(): RustGalleryItemInfo = when (this) {
+ is GalleryItemInfo.Image -> {
+ RustGalleryItemInfo.Image(
+ imageInfo = imageInfo.map(),
+ source = RustUploadSource.File(file.path),
+ caption = null,
+ formattedCaption = null,
+ thumbnailSource = thumbnailFile?.path?.let(RustUploadSource::File),
+ )
+ }
+ is GalleryItemInfo.Video -> {
+ RustGalleryItemInfo.Video(
+ videoInfo = videoInfo.map(),
+ source = RustUploadSource.File(file.path),
+ caption = null,
+ formattedCaption = null,
+ thumbnailSource = thumbnailFile?.path?.let(RustUploadSource::File),
+ )
+ }
+ is GalleryItemInfo.Audio -> {
+ RustGalleryItemInfo.Audio(
+ audioInfo = audioInfo.map(),
+ source = RustUploadSource.File(file.path),
+ caption = null,
+ formattedCaption = null,
+ )
+ }
+ is GalleryItemInfo.MediaFile -> {
+ RustGalleryItemInfo.File(
+ fileInfo = fileInfo.map(),
+ source = RustUploadSource.File(file.path),
+ caption = null,
+ formattedCaption = null,
+ )
+ }
+}
diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/media/GalleryMediaUploadHandlerImpl.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/media/GalleryMediaUploadHandlerImpl.kt
new file mode 100644
index 0000000000..5bb01cdb56
--- /dev/null
+++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/media/GalleryMediaUploadHandlerImpl.kt
@@ -0,0 +1,34 @@
+/*
+ * Copyright (c) 2026 Element Creations Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
+ * Please see LICENSE files in the repository root for full details.
+ */
+
+package io.element.android.libraries.matrix.impl.media
+
+import io.element.android.libraries.androidutils.file.safeDelete
+import io.element.android.libraries.core.extensions.runCatchingExceptions
+import io.element.android.libraries.matrix.api.media.MediaUploadHandler
+import org.matrix.rustcomponents.sdk.SendGalleryJoinHandle
+import java.io.File
+
+class GalleryMediaUploadHandlerImpl(
+ private val filesToUpload: List,
+ private val sendGalleryJoinHandle: SendGalleryJoinHandle,
+) : MediaUploadHandler {
+ override suspend fun await(): Result =
+ runCatchingExceptions {
+ sendGalleryJoinHandle.join()
+ }
+ .also { cleanUpFiles() }
+
+ override fun cancel() {
+ sendGalleryJoinHandle.cancel()
+ cleanUpFiles()
+ }
+
+ private fun cleanUpFiles() {
+ filesToUpload.forEach { file -> file.safeDelete() }
+ }
+}
diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/notification/TimelineEventToNotificationContentMapper.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/notification/TimelineEventToNotificationContentMapper.kt
index 7e65a1cc5c..84fd2e80e8 100644
--- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/notification/TimelineEventToNotificationContentMapper.kt
+++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/notification/TimelineEventToNotificationContentMapper.kt
@@ -97,7 +97,10 @@ private fun MessageLikeEventContent.toContent(senderId: UserId): NotificationCon
is MessageLikeEventContent.ReactionContent -> NotificationContent.MessageLike.ReactionContent(relatedEventId)
MessageLikeEventContent.RoomEncrypted -> NotificationContent.MessageLike.RoomEncrypted
is MessageLikeEventContent.RoomMessage -> {
- NotificationContent.MessageLike.RoomMessage(senderId, EventMessageMapper().mapMessageType(messageType))
+ NotificationContent.MessageLike.RoomMessage(
+ senderId,
+ EventMessageMapper().mapMessageType(messageType)
+ )
}
is MessageLikeEventContent.RoomRedaction -> NotificationContent.MessageLike.RoomRedaction(
redactedEventId = redactedEventId?.let(::EventId),
diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/paths/SessionPaths.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/paths/SessionPaths.kt
index 1888cd0f88..8a2403f772 100644
--- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/paths/SessionPaths.kt
+++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/paths/SessionPaths.kt
@@ -8,19 +8,10 @@
package io.element.android.libraries.matrix.impl.paths
+import io.element.android.libraries.matrix.api.paths.SessionPaths
import io.element.android.libraries.sessionstorage.api.SessionData
import java.io.File
-data class SessionPaths(
- val fileDirectory: File,
- val cacheDirectory: File,
-) {
- fun deleteRecursively() {
- fileDirectory.deleteRecursively()
- cacheDirectory.deleteRecursively()
- }
-}
-
internal fun SessionData.getSessionPaths(): SessionPaths {
return SessionPaths(
fileDirectory = File(sessionPath),
diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/paths/SessionPathsFactory.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/paths/SessionPathsFactory.kt
index d2e519fa9e..f40c134209 100644
--- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/paths/SessionPathsFactory.kt
+++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/paths/SessionPathsFactory.kt
@@ -11,6 +11,7 @@ package io.element.android.libraries.matrix.impl.paths
import dev.zacsweers.metro.Inject
import io.element.android.libraries.di.BaseDirectory
import io.element.android.libraries.di.CacheDirectory
+import io.element.android.libraries.matrix.api.paths.SessionPaths
import java.io.File
import java.util.UUID
diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/JoinedRustRoom.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/JoinedRustRoom.kt
index 1b02c03e30..68fcf6287c 100644
--- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/JoinedRustRoom.kt
+++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/JoinedRustRoom.kt
@@ -8,6 +8,7 @@
package io.element.android.libraries.matrix.impl.room
+import io.element.android.appconfig.TimelineConfig
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.core.coroutine.childScope
import io.element.android.libraries.core.extensions.mapFailure
@@ -218,11 +219,18 @@ class JoinedRustRoom(
RoomMessageEventMessageType.IMAGE,
RoomMessageEventMessageType.VIDEO,
RoomMessageEventMessageType.AUDIO,
+ RoomMessageEventMessageType.GALLERY,
)
)
is CreateTimelineParams.Focused,
CreateTimelineParams.PinnedOnly,
- is CreateTimelineParams.Threaded -> TimelineFilter.All
+ is CreateTimelineParams.Threaded -> {
+ RustTimelineEventFilterFactory().create(
+ joinRule = roomInfoFlow.value.joinRule,
+ isEncrypted = roomInfoFlow.value.isEncrypted,
+ excludedStateTypes = TimelineConfig.excludedEvents,
+ )?.let(TimelineFilter::EventFilter) ?: TimelineFilter.All
+ }
}
val internalIdPrefix = when (createTimelineParams) {
@@ -548,6 +556,12 @@ class JoinedRustRoom(
}
}
+ override suspend fun setOwnMemberDisplayName(displayName: String): Result = withContext(roomDispatcher) {
+ runCatchingExceptions {
+ innerRoom.setOwnMemberDisplayName(displayName)
+ }
+ }
+
override fun close() = destroy()
override fun destroy() {
diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/MessageEventType.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/MessageEventType.kt
index 47b37b7923..bb1460863f 100644
--- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/MessageEventType.kt
+++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/MessageEventType.kt
@@ -9,7 +9,8 @@
package io.element.android.libraries.matrix.impl.room
import io.element.android.libraries.matrix.api.room.MessageEventType
-import org.matrix.rustcomponents.sdk.MessageLikeEventType
+import uniffi.ruma_events.MessageLikeEventType
+import uniffi.ruma_events.messageLikeEventTypeFromString
fun MessageEventType.map(): MessageLikeEventType = when (this) {
MessageEventType.Audio -> MessageLikeEventType.Audio
@@ -51,7 +52,7 @@ fun MessageEventType.map(): MessageLikeEventType = when (this) {
MessageEventType.UnstablePollStart -> MessageLikeEventType.UnstablePollStart
MessageEventType.Video -> MessageLikeEventType.Video
MessageEventType.Voice -> MessageLikeEventType.Voice
- is MessageEventType.Other -> MessageLikeEventType.Other(type)
+ is MessageEventType.Other -> messageLikeEventTypeFromString(type)
}
fun MessageLikeEventType.map(): MessageEventType = when (this) {
@@ -94,5 +95,5 @@ fun MessageLikeEventType.map(): MessageEventType = when (this) {
MessageLikeEventType.RtcDecline -> MessageEventType.RtcDecline
MessageLikeEventType.Video -> MessageEventType.Video
MessageLikeEventType.Voice -> MessageEventType.Voice
- is MessageLikeEventType.Other -> MessageEventType.Other(v1)
+ is MessageLikeEventType.Custom -> MessageEventType.Other(type = toString())
}
diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RoomContentForwarder.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RoomContentForwarder.kt
index e000c2f916..39cde68c31 100644
--- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RoomContentForwarder.kt
+++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RoomContentForwarder.kt
@@ -49,7 +49,7 @@ class RoomContentForwarder(
val content = (messageLikeContent.kind as? MsgLikeKind.Message)?.content
?: throw ForwardEventException(toRoomIds)
- val targetRooms = toRoomIds.mapNotNull { roomId -> roomListService.roomOrNull(roomId.value) }
+ val targetRooms = toRoomIds.toSet().mapNotNull { roomId -> roomListService.roomOrNull(roomId.value) }
val failedForwardingTo = mutableSetOf()
targetRooms.parallelMap { room ->
room.use { targetRoom ->
diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RustBaseRoom.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RustBaseRoom.kt
index 0551891a6d..7c5cd10658 100644
--- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RustBaseRoom.kt
+++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RustBaseRoom.kt
@@ -200,12 +200,6 @@ class RustBaseRoom(
}
}
- override suspend fun clearEventCacheStorage(): Result = withContext(roomDispatcher) {
- runCatchingExceptions {
- innerRoom.clearEventCacheStorage()
- }
- }
-
override suspend fun setIsFavorite(isFavorite: Boolean): Result = withContext(roomDispatcher) {
runCatchingExceptions {
innerRoom.setIsFavourite(isFavorite, null)
diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RustRoomFactory.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RustRoomFactory.kt
index 15a0850184..b5f9c3ee80 100644
--- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RustRoomFactory.kt
+++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RustRoomFactory.kt
@@ -21,6 +21,7 @@ import io.element.android.libraries.matrix.api.room.JoinedRoom
import io.element.android.libraries.matrix.api.room.RoomMembershipObserver
import io.element.android.libraries.matrix.api.roomlist.RoomListService
import io.element.android.libraries.matrix.api.roomlist.awaitLoaded
+import io.element.android.libraries.matrix.impl.room.join.map
import io.element.android.libraries.matrix.impl.room.preview.RoomPreviewInfoMapper
import io.element.android.libraries.matrix.impl.roomlist.roomOrNull
import io.element.android.services.analytics.api.AnalyticsLongRunningTransaction
@@ -41,6 +42,7 @@ import org.matrix.rustcomponents.sdk.TimelineConfiguration
import org.matrix.rustcomponents.sdk.TimelineFilter
import org.matrix.rustcomponents.sdk.TimelineFocus
import timber.log.Timber
+import uniffi.matrix_sdk_base.EncryptionState
import uniffi.matrix_sdk_ui.TimelineReadReceiptTracking
import java.util.concurrent.atomic.AtomicBoolean
import org.matrix.rustcomponents.sdk.RoomListService as InnerRoomListService
@@ -66,12 +68,6 @@ class RustRoomFactory(
private val mutex = Mutex()
private val isDestroyed: AtomicBoolean = AtomicBoolean(false)
- private val eventFilters = TimelineConfig.excludedEvents
- .takeIf { it.isNotEmpty() }
- ?.let { listStateEventType ->
- timelineEventFilterFactory.create(listStateEventType)
- }
-
suspend fun destroy() {
withContext(NonCancellable + dispatcher) {
mutex.withLock {
@@ -128,10 +124,20 @@ class RustRoomFactory(
operation = "sdkRoom.timelineWithConfiguration",
description = "Get timeline from the SDK",
) {
+ val isEncrypted = when (roomInfo.encryptionState) {
+ EncryptionState.ENCRYPTED -> true
+ EncryptionState.NOT_ENCRYPTED -> false
+ EncryptionState.UNKNOWN -> null
+ }
+ val timelineFilter = timelineEventFilterFactory.create(
+ joinRule = roomInfo.joinRule?.map(),
+ isEncrypted = isEncrypted,
+ excludedStateTypes = TimelineConfig.excludedEvents,
+ )
sdkRoom.timelineWithConfiguration(
TimelineConfiguration(
focus = TimelineFocus.Live(hideThreadedEvents = hideThreadedEvents),
- filter = eventFilters?.let(TimelineFilter::EventFilter) ?: TimelineFilter.All,
+ filter = timelineFilter?.let(TimelineFilter::EventFilter) ?: TimelineFilter.All,
internalIdPrefix = "live",
dateDividerMode = DateDividerMode.DAILY,
trackReadReceipts = TimelineReadReceiptTracking.ALL_EVENTS,
diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/StateEventType.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/StateEventType.kt
index 897d9a34cb..13a040525e 100644
--- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/StateEventType.kt
+++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/StateEventType.kt
@@ -9,7 +9,8 @@
package io.element.android.libraries.matrix.impl.room
import io.element.android.libraries.matrix.api.room.StateEventType
-import org.matrix.rustcomponents.sdk.StateEventType as RustStateEventType
+import uniffi.ruma_events.stateEventTypeFromString
+import uniffi.ruma_events.StateEventType as RustStateEventType
fun StateEventType.map(): RustStateEventType = when (this) {
StateEventType.PolicyRuleRoom -> RustStateEventType.PolicyRuleRoom
@@ -23,7 +24,7 @@ fun StateEventType.map(): RustStateEventType = when (this) {
StateEventType.RoomGuestAccess -> RustStateEventType.RoomGuestAccess
StateEventType.RoomHistoryVisibility -> RustStateEventType.RoomHistoryVisibility
StateEventType.RoomJoinRules -> RustStateEventType.RoomJoinRules
- StateEventType.RoomMemberEvent -> RustStateEventType.RoomMemberEvent
+ StateEventType.RoomMemberEvent -> RustStateEventType.RoomMember
StateEventType.RoomName -> RustStateEventType.RoomName
StateEventType.RoomPinnedEvents -> RustStateEventType.RoomPinnedEvents
StateEventType.RoomPowerLevels -> RustStateEventType.RoomPowerLevels
@@ -37,7 +38,8 @@ fun StateEventType.map(): RustStateEventType = when (this) {
StateEventType.MemberHints -> RustStateEventType.MemberHints
StateEventType.RoomImagePack -> RustStateEventType.RoomImagePack
StateEventType.RoomLanguage -> RustStateEventType.RoomLanguage
- is StateEventType.Custom -> RustStateEventType.Custom(type)
+ StateEventType.RoomPolicy -> RustStateEventType.RoomPolicy
+ is StateEventType.Custom -> stateEventTypeFromString(type)
}
fun RustStateEventType.map(): StateEventType = when (this) {
@@ -52,7 +54,7 @@ fun RustStateEventType.map(): StateEventType = when (this) {
RustStateEventType.RoomGuestAccess -> StateEventType.RoomGuestAccess
RustStateEventType.RoomHistoryVisibility -> StateEventType.RoomHistoryVisibility
RustStateEventType.RoomJoinRules -> StateEventType.RoomJoinRules
- RustStateEventType.RoomMemberEvent -> StateEventType.RoomMemberEvent
+ RustStateEventType.RoomMember -> StateEventType.RoomMemberEvent
RustStateEventType.RoomName -> StateEventType.RoomName
RustStateEventType.RoomPinnedEvents -> StateEventType.RoomPinnedEvents
RustStateEventType.RoomPowerLevels -> StateEventType.RoomPowerLevels
@@ -66,5 +68,6 @@ fun RustStateEventType.map(): StateEventType = when (this) {
RustStateEventType.MemberHints -> StateEventType.MemberHints
RustStateEventType.RoomImagePack -> StateEventType.RoomImagePack
RustStateEventType.RoomLanguage -> StateEventType.RoomLanguage
- is RustStateEventType.Custom -> StateEventType.Custom(value)
+ RustStateEventType.RoomPolicy -> StateEventType.RoomPolicy
+ is RustStateEventType.Custom -> StateEventType.Custom(this.toString())
}
diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/TimelineEventFilterFactory.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/TimelineEventFilterFactory.kt
index b4735fdaf3..a966acaa2b 100644
--- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/TimelineEventFilterFactory.kt
+++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/TimelineEventFilterFactory.kt
@@ -11,20 +11,45 @@ package io.element.android.libraries.matrix.impl.room
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesBinding
import io.element.android.libraries.matrix.api.room.StateEventType
+import io.element.android.libraries.matrix.api.room.join.JoinRule
+import org.matrix.rustcomponents.sdk.FilterTimelineEventCondition
import org.matrix.rustcomponents.sdk.FilterTimelineEventType
import org.matrix.rustcomponents.sdk.TimelineEventFilter
+import uniffi.matrix_sdk_ui.MembershipChangeFilter
interface TimelineEventFilterFactory {
- fun create(listStateEventType: List): TimelineEventFilter
+ fun create(
+ joinRule: JoinRule?,
+ isEncrypted: Boolean?,
+ excludedStateTypes: List
+ ): TimelineEventFilter?
}
@ContributesBinding(AppScope::class)
class RustTimelineEventFilterFactory : TimelineEventFilterFactory {
- override fun create(listStateEventType: List): TimelineEventFilter {
- return TimelineEventFilter.excludeEventTypes(
- listStateEventType.map { stateEventType ->
- FilterTimelineEventType.State(stateEventType.map())
- }
- )
+ override fun create(
+ joinRule: JoinRule?,
+ isEncrypted: Boolean?,
+ excludedStateTypes: List
+ ): TimelineEventFilter? {
+ val excludedEventTypes = excludedStateTypes.map {
+ FilterTimelineEventCondition.EventType(FilterTimelineEventType.State(it.map()))
+ }
+ // If the room is publicly joinable and not encrypted, we also want to exclude membership changes and profile changes,
+ // as they will pollute the timelines since they're quite common and not add much value.
+ val excludedMembershipChanges = if (joinRule !is JoinRule.Invite && isEncrypted == false) {
+ listOf(
+ FilterTimelineEventCondition.MembershipChange(MembershipChangeFilter.JOIN),
+ FilterTimelineEventCondition.MembershipChange(MembershipChangeFilter.LEAVE),
+ FilterTimelineEventCondition.ProfileChange,
+ )
+ } else {
+ emptyList()
+ }
+ return if (excludedEventTypes.isNotEmpty() || excludedMembershipChanges.isNotEmpty()) {
+ TimelineEventFilter.exclude(excludedEventTypes + excludedMembershipChanges)
+ } else {
+ null
+ }
}
}
diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/storage/SqliteStoreBuilder.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/storage/SqliteStoreBuilder.kt
index 3fdfb7a179..4cfcf1a95e 100644
--- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/storage/SqliteStoreBuilder.kt
+++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/storage/SqliteStoreBuilder.kt
@@ -10,7 +10,7 @@ package io.element.android.libraries.matrix.impl.storage
import io.element.android.libraries.androidutils.crypto.ClientSecret
import io.element.android.libraries.core.data.ByteUnit
import io.element.android.libraries.core.data.megaBytes
-import io.element.android.libraries.matrix.impl.paths.SessionPaths
+import io.element.android.libraries.matrix.api.paths.SessionPaths
import org.matrix.rustcomponents.sdk.ClientBuilder
import org.matrix.rustcomponents.sdk.SqliteStoreBuilder as SdkSqliteStoreBuilder
diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/storage/SqliteStoreBuilderProvider.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/storage/SqliteStoreBuilderProvider.kt
index 29a30f3c56..5d79e72403 100644
--- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/storage/SqliteStoreBuilderProvider.kt
+++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/storage/SqliteStoreBuilderProvider.kt
@@ -9,7 +9,7 @@ package io.element.android.libraries.matrix.impl.storage
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesBinding
-import io.element.android.libraries.matrix.impl.paths.SessionPaths
+import io.element.android.libraries.matrix.api.paths.SessionPaths
interface SqliteStoreBuilderProvider {
fun provide(sessionPaths: SessionPaths): SqliteStoreBuilder
diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/RustTimeline.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/RustTimeline.kt
index d44676d34a..bd756bc002 100644
--- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/RustTimeline.kt
+++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/RustTimeline.kt
@@ -15,6 +15,7 @@ import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.media.AudioInfo
import io.element.android.libraries.matrix.api.media.FileInfo
+import io.element.android.libraries.matrix.api.media.GalleryItemInfo
import io.element.android.libraries.matrix.api.media.ImageInfo
import io.element.android.libraries.matrix.api.media.MediaUploadHandler
import io.element.android.libraries.matrix.api.media.VideoInfo
@@ -30,6 +31,7 @@ import io.element.android.libraries.matrix.api.timeline.Timeline
import io.element.android.libraries.matrix.api.timeline.TimelineException
import io.element.android.libraries.matrix.api.timeline.item.event.EventOrTransactionId
import io.element.android.libraries.matrix.api.timeline.item.event.InReplyTo
+import io.element.android.libraries.matrix.impl.media.GalleryMediaUploadHandlerImpl
import io.element.android.libraries.matrix.impl.media.MediaUploadHandlerImpl
import io.element.android.libraries.matrix.impl.media.map
import io.element.android.libraries.matrix.impl.poll.toInner
@@ -38,8 +40,6 @@ import io.element.android.libraries.matrix.impl.room.location.into
import io.element.android.libraries.matrix.impl.timeline.item.event.EventTimelineItemMapper
import io.element.android.libraries.matrix.impl.timeline.item.event.TimelineEventContentMapper
import io.element.android.libraries.matrix.impl.timeline.item.virtual.VirtualTimelineItemMapper
-import io.element.android.libraries.matrix.impl.timeline.postprocessor.FilterEmptyDayPostProcessor
-import io.element.android.libraries.matrix.impl.timeline.postprocessor.FilterPublicMembershipChangesPostProcessor
import io.element.android.libraries.matrix.impl.timeline.postprocessor.LastForwardIndicatorsPostProcessor
import io.element.android.libraries.matrix.impl.timeline.postprocessor.LoadingIndicatorsPostProcessor
import io.element.android.libraries.matrix.impl.timeline.postprocessor.RoomBeginningPostProcessor
@@ -69,6 +69,7 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.matrix.rustcomponents.sdk.EditedContent
import org.matrix.rustcomponents.sdk.FormattedBody
+import org.matrix.rustcomponents.sdk.GalleryUploadParameters
import org.matrix.rustcomponents.sdk.MessageFormat
import org.matrix.rustcomponents.sdk.PollData
import org.matrix.rustcomponents.sdk.SendAttachmentJoinHandle
@@ -125,8 +126,6 @@ class RustTimeline(
private val loadingIndicatorsPostProcessor = LoadingIndicatorsPostProcessor(systemClock)
private val lastForwardIndicatorsPostProcessor = LastForwardIndicatorsPostProcessor(mode)
private val typingNotificationPostProcessor = TypingNotificationPostProcessor(mode)
- private val publicMembershipChangesPostProcessor = FilterPublicMembershipChangesPostProcessor()
- private val emptyDayPostProcessor = FilterEmptyDayPostProcessor()
private data class RoomTimelineInfo(
val roomCreators: ImmutableList,
@@ -252,18 +251,6 @@ class RustTimeline(
hasMoreToLoadBackwards = backwardPaginationStatus.hasMoreToLoad,
)
}
- // This should be the first post processor after room beginning.
- .let { items ->
- publicMembershipChangesPostProcessor.process(
- items = items,
- joinRule = joinRule,
- isEncrypted = isEncrypted,
- )
- }
- // After removing public membership changes, we might end up with empty days, so we need to filter them out.
- .let { items ->
- emptyDayPostProcessor.process(items)
- }
.let { items ->
loadingIndicatorsPostProcessor.process(
items = items,
@@ -551,6 +538,42 @@ class RustTimeline(
}
}
+ override suspend fun sendGallery(
+ items: List,
+ caption: String?,
+ formattedCaption: String?,
+ inReplyToEventId: EventId?,
+ ): Result {
+ Timber.d("Sending gallery with ${items.size} items")
+ val allFiles = items.flatMap { item ->
+ when (item) {
+ is GalleryItemInfo.Image -> listOfNotNull(item.file, item.thumbnailFile)
+ is GalleryItemInfo.Video -> listOfNotNull(item.file, item.thumbnailFile)
+ is GalleryItemInfo.Audio -> listOf(item.file)
+ is GalleryItemInfo.MediaFile -> listOf(item.file)
+ }
+ }
+ return sendGalleryAttachment(allFiles) {
+ inner.sendGallery(
+ params = GalleryUploadParameters(
+ caption = caption,
+ formattedCaption = formattedCaption?.let {
+ FormattedBody(body = it, format = MessageFormat.Html)
+ },
+ mentions = null,
+ inReplyTo = inReplyToEventId?.value,
+ ),
+ itemInfos = items.map { it.map() },
+ )
+ }
+ }
+
+ private fun sendGalleryAttachment(files: List, handle: () -> org.matrix.rustcomponents.sdk.SendGalleryJoinHandle): Result {
+ return runCatchingExceptions {
+ GalleryMediaUploadHandlerImpl(files, handle())
+ }
+ }
+
override suspend fun createPoll(
question: String,
answers: List,
@@ -637,6 +660,24 @@ class RustTimeline(
}
}
+ override suspend fun isEventLoaded(eventId: EventId): Boolean = withContext(dispatcher) {
+ val isInLoadedItems = _timelineItems.replayCache.firstOrNull().orEmpty().any { timelineItem ->
+ timelineItem is MatrixTimelineItem.Event && timelineItem.eventId == eventId
+ }
+ if (isInLoadedItems) {
+ // Displayed events are caught above. The SDK-level list still holds events the display
+ // layer later drops, so this avoids the FFI call for in-window-but-not-rendered events.
+ true
+ } else {
+ // getEventTimelineItemByEventId throws when the event isn't in the loaded window.
+ // EventTimelineItem is a Disposable wrapping a native handle, so release it once we've
+ // confirmed presence — otherwise the handle lingers until the GC cleaner runs.
+ runCatchingExceptions {
+ inner.getEventTimelineItemByEventId(eventId.value).use { /* presence is all we need */ }
+ }.isSuccess
+ }
+ }
+
override suspend fun pinEvent(eventId: EventId): Result = withContext(dispatcher) {
runCatchingExceptions {
inner.pinEvent(eventId = eventId.value)
diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/item/event/EventMessageMapper.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/item/event/EventMessageMapper.kt
index e382bce8db..0811cd741d 100644
--- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/item/event/EventMessageMapper.kt
+++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/item/event/EventMessageMapper.kt
@@ -13,6 +13,8 @@ import io.element.android.libraries.matrix.api.timeline.item.event.AudioMessageT
import io.element.android.libraries.matrix.api.timeline.item.event.EmoteMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.FileMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.FormattedBody
+import io.element.android.libraries.matrix.api.timeline.item.event.GalleryItemType
+import io.element.android.libraries.matrix.api.timeline.item.event.GalleryMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.ImageMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.InReplyTo
import io.element.android.libraries.matrix.api.timeline.item.event.LocationMessageType
@@ -31,12 +33,10 @@ import org.matrix.rustcomponents.sdk.MessageType
import org.matrix.rustcomponents.sdk.MsgLikeKind
import org.matrix.rustcomponents.sdk.use
import org.matrix.rustcomponents.sdk.FormattedBody as RustFormattedBody
+import org.matrix.rustcomponents.sdk.GalleryItemType as RustGalleryItemType
import org.matrix.rustcomponents.sdk.MessageFormat as RustMessageFormat
import org.matrix.rustcomponents.sdk.MessageType as RustMessageType
-// https://github.com/Johennes/matrix-spec-proposals/blob/johannes/msgtype-galleries/proposals/4274-inline-media-galleries.md#unstable-prefix
-private const val MSG_TYPE_GALLERY_UNSTABLE = "dm.filament.gallery"
-
class EventMessageMapper {
private val inReplyToMapper by lazy { InReplyToMapper(TimelineEventContentMapper()) }
@@ -124,8 +124,64 @@ class EventMessageMapper {
OtherMessageType(type.msgtype, type.body)
}
is MessageType.Gallery -> {
- // TODO expose the GalleryType.
- OtherMessageType(MSG_TYPE_GALLERY_UNSTABLE, type.content.body)
+ GalleryMessageType(
+ body = type.content.body,
+ formatted = type.content.formatted?.map(),
+ items = type.content.itemtypes.map { mapGalleryItemType(it) },
+ )
+ }
+ }
+
+ private fun mapGalleryItemType(type: RustGalleryItemType): GalleryItemType = when (type) {
+ is RustGalleryItemType.Image -> {
+ GalleryItemType.Image(
+ content = ImageMessageType(
+ filename = type.content.filename,
+ caption = type.content.caption,
+ formattedCaption = type.content.formattedCaption?.map(),
+ source = type.content.source.map(),
+ info = type.content.info?.map(),
+ )
+ )
+ }
+ is RustGalleryItemType.Audio -> {
+ GalleryItemType.Audio(
+ content = AudioMessageType(
+ filename = type.content.filename,
+ caption = type.content.caption,
+ formattedCaption = type.content.formattedCaption?.map(),
+ source = type.content.source.map(),
+ info = type.content.info?.map(),
+ )
+ )
+ }
+ is RustGalleryItemType.Video -> {
+ GalleryItemType.Video(
+ content = VideoMessageType(
+ filename = type.content.filename,
+ caption = type.content.caption,
+ formattedCaption = type.content.formattedCaption?.map(),
+ source = type.content.source.map(),
+ info = type.content.info?.map(),
+ )
+ )
+ }
+ is RustGalleryItemType.File -> {
+ GalleryItemType.File(
+ content = FileMessageType(
+ filename = type.content.filename,
+ caption = type.content.caption,
+ formattedCaption = type.content.formattedCaption?.map(),
+ source = type.content.source.map(),
+ info = type.content.info?.map(),
+ )
+ )
+ }
+ is RustGalleryItemType.Other -> {
+ GalleryItemType.Other(
+ itemType = type.itemtype,
+ body = type.body,
+ )
}
}
}
diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/postprocessor/FilterEmptyDayPostProcessor.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/postprocessor/FilterEmptyDayPostProcessor.kt
deleted file mode 100644
index b7dceafa25..0000000000
--- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/postprocessor/FilterEmptyDayPostProcessor.kt
+++ /dev/null
@@ -1,38 +0,0 @@
-/*
- * Copyright (c) 2026 Element Creations Ltd.
- *
- * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
- * Please see LICENSE files in the repository root for full details.
- */
-
-package io.element.android.libraries.matrix.impl.timeline.postprocessor
-
-import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem
-import io.element.android.libraries.matrix.api.timeline.item.virtual.VirtualTimelineItem
-
-/**
- * Post-processor to filter out day separators for days that don't contain any events.
- */
-class FilterEmptyDayPostProcessor {
- /**
- * Filters out day separators from [items] for days that don't contain any events.
- */
- fun process(items: List): List = buildList {
- // The timeline is ordered by descending timestamp, so events that happened during a day appear before the day separator for that day.
- // We can use this to determine if a day separator should be kept or not.
- var hasEvent = false
- for (item in items) {
- if (item is MatrixTimelineItem.Event) {
- hasEvent = true
- add(item)
- } else if (item is MatrixTimelineItem.Virtual && item.virtual is VirtualTimelineItem.DayDivider) {
- if (hasEvent) {
- add(item)
- hasEvent = false
- }
- } else {
- add(item)
- }
- }
- }
-}
diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/postprocessor/FilterPublicMembershipChangesPostProcessor.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/postprocessor/FilterPublicMembershipChangesPostProcessor.kt
deleted file mode 100644
index 806c9369bf..0000000000
--- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/postprocessor/FilterPublicMembershipChangesPostProcessor.kt
+++ /dev/null
@@ -1,43 +0,0 @@
-/*
- * Copyright (c) 2026 Element Creations Ltd.
- *
- * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
- * Please see LICENSE files in the repository root for full details.
- */
-
-package io.element.android.libraries.matrix.impl.timeline.postprocessor
-
-import io.element.android.libraries.matrix.api.room.join.JoinRule
-import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem
-import io.element.android.libraries.matrix.api.timeline.item.event.MembershipChange
-import io.element.android.libraries.matrix.api.timeline.item.event.ProfileChangeContent
-import io.element.android.libraries.matrix.api.timeline.item.event.RoomMembershipContent
-
-/**
- * Post-processor to filter out public membership changes for non-encrypted, publicly joinable rooms.
- */
-class FilterPublicMembershipChangesPostProcessor {
- /**
- * Filters out public membership changes from [items] if the room is publicly joinable and not encrypted.
- */
- fun process(
- items: List,
- joinRule: JoinRule?,
- isEncrypted: Boolean?,
- ): List {
- return if (joinRule !is JoinRule.Invite && isEncrypted == false) {
- filterMembershipEvents(items)
- } else {
- items
- }
- }
-
- private fun filterMembershipEvents(items: List): List = items.filter { item ->
- val eventContent = (item as? MatrixTimelineItem.Event)?.event?.content ?: return@filter true
- when (eventContent) {
- is RoomMembershipContent -> eventContent.change != null && eventContent.change !in listOf(MembershipChange.JOINED, MembershipChange.LEFT)
- is ProfileChangeContent -> false
- else -> true
- }
- }
-}
diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/util/SessionPathsProvider.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/util/SessionPathsProvider.kt
deleted file mode 100644
index 0030c6e23e..0000000000
--- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/util/SessionPathsProvider.kt
+++ /dev/null
@@ -1,23 +0,0 @@
-/*
- * Copyright (c) 2025 Element Creations Ltd.
- * Copyright 2024, 2025 New Vector Ltd.
- *
- * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
- * Please see LICENSE files in the repository root for full details.
- */
-
-package io.element.android.libraries.matrix.impl.util
-
-import io.element.android.libraries.matrix.api.core.SessionId
-import io.element.android.libraries.matrix.impl.paths.SessionPaths
-import io.element.android.libraries.matrix.impl.paths.getSessionPaths
-import io.element.android.libraries.sessionstorage.api.SessionStore
-
-class SessionPathsProvider(
- private val sessionStore: SessionStore,
-) {
- suspend fun provides(sessionId: SessionId): SessionPaths? {
- val sessionData = sessionStore.getSession(sessionId.value) ?: return null
- return sessionData.getSessionPaths()
- }
-}
diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClientFactoryTest.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClientFactoryTest.kt
index 670430e23e..1c25acf088 100644
--- a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClientFactoryTest.kt
+++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClientFactoryTest.kt
@@ -12,7 +12,6 @@ import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.impl.auth.FakeProxyProvider
-import io.element.android.libraries.matrix.impl.auth.FakeUserCertificatesProvider
import io.element.android.libraries.matrix.impl.room.FakeTimelineEventFilterFactory
import io.element.android.libraries.matrix.impl.storage.FakeSqliteStoreBuilderProvider
import io.element.android.libraries.network.useragent.SimpleUserAgentProvider
@@ -58,7 +57,6 @@ fun TestScope.createRustMatrixClientFactory(
coroutineDispatchers = testCoroutineDispatchers(),
sessionStore = sessionStore,
userAgentProvider = SimpleUserAgentProvider(),
- userCertificatesProvider = FakeUserCertificatesProvider(),
proxyProvider = FakeProxyProvider(),
clock = FakeSystemClock(),
analyticsService = FakeAnalyticsService(),
diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClientTest.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClientTest.kt
index ed8440fe53..a8e812d031 100644
--- a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClientTest.kt
+++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClientTest.kt
@@ -13,6 +13,7 @@ package io.element.android.libraries.matrix.impl
import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.core.data.bytes
import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
+import io.element.android.libraries.matrix.api.paths.SessionPaths
import io.element.android.libraries.matrix.impl.fixtures.fakes.FakeFfiClient
import io.element.android.libraries.matrix.impl.fixtures.fakes.FakeFfiSyncService
import io.element.android.libraries.matrix.impl.room.FakeTimelineEventFilterFactory
@@ -141,6 +142,7 @@ class RustMatrixClientTest {
),
) = RustMatrixClient(
innerClient = client,
+ sessionPaths = SessionPaths(fileDirectory = File("files"), cacheDirectory = File("cache")),
sessionStore = sessionStore,
appCoroutineScope = backgroundScope,
sessionDelegate = aRustClientSessionDelegate(
diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/auth/FakeUserCertificatesProvider.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/auth/FakeUserCertificatesProvider.kt
deleted file mode 100644
index 955af4a23d..0000000000
--- a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/auth/FakeUserCertificatesProvider.kt
+++ /dev/null
@@ -1,16 +0,0 @@
-/*
- * Copyright (c) 2026 Element Creations Ltd.
- *
- * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
- * Please see LICENSE files in the repository root for full details.
- */
-
-package io.element.android.libraries.matrix.impl.auth
-
-import io.element.android.libraries.matrix.impl.certificates.UserCertificatesProvider
-
-class FakeUserCertificatesProvider : UserCertificatesProvider {
- override fun provides(): List {
- return emptyList()
- }
-}
diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/auth/RustHomeserverLoginCompatibilityCheckerTest.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/auth/RustHomeserverLoginCompatibilityCheckerTest.kt
index 903273113b..862019091b 100644
--- a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/auth/RustHomeserverLoginCompatibilityCheckerTest.kt
+++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/auth/RustHomeserverLoginCompatibilityCheckerTest.kt
@@ -49,6 +49,5 @@ class RustHomeserverLoginCompatibilityCheckerTest {
FakeFfiClient(homeserverLoginDetailsResult = result)
}
},
- userCertificatesProvider = FakeUserCertificatesProvider(),
)
}
diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/factories/TimelineEventContentMessageLike.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/factories/TimelineEventContentMessageLike.kt
index 877f2056e9..96303a257b 100644
--- a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/factories/TimelineEventContentMessageLike.kt
+++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/factories/TimelineEventContentMessageLike.kt
@@ -8,12 +8,26 @@
package io.element.android.libraries.matrix.impl.fixtures.factories
+import io.element.android.libraries.matrix.impl.fixtures.fakes.FakeFfiMediaSource
import io.element.android.libraries.matrix.test.A_MESSAGE
+import org.matrix.rustcomponents.sdk.AudioInfo
+import org.matrix.rustcomponents.sdk.AudioMessageContent
+import org.matrix.rustcomponents.sdk.FileInfo
+import org.matrix.rustcomponents.sdk.FileMessageContent
import org.matrix.rustcomponents.sdk.FormattedBody
+import org.matrix.rustcomponents.sdk.GalleryItemType
+import org.matrix.rustcomponents.sdk.GalleryMessageContent
+import org.matrix.rustcomponents.sdk.ImageInfo
+import org.matrix.rustcomponents.sdk.ImageMessageContent
+import org.matrix.rustcomponents.sdk.MediaSource
import org.matrix.rustcomponents.sdk.MessageLikeEventContent
import org.matrix.rustcomponents.sdk.MessageType
import org.matrix.rustcomponents.sdk.TextMessageContent
import org.matrix.rustcomponents.sdk.TimelineEventContent
+import org.matrix.rustcomponents.sdk.UnstableAudioDetailsContent
+import org.matrix.rustcomponents.sdk.UnstableVoiceContent
+import org.matrix.rustcomponents.sdk.VideoInfo
+import org.matrix.rustcomponents.sdk.VideoMessageContent
internal fun aRustTimelineEventContentMessageLike(
content: MessageLikeEventContent = aRustMessageLikeEventContentRoomMessage(),
@@ -42,3 +56,94 @@ internal fun aRustTextMessageContent(
body = body,
formatted = formatted,
)
+
+internal fun aRustMessageTypeGallery(
+ content: GalleryMessageContent = aRustGalleryMessageContent(),
+) = MessageType.Gallery(content = content)
+
+internal fun aRustGalleryMessageContent(
+ body: String = "A gallery",
+ formatted: FormattedBody? = null,
+ itemTypes: List = listOf(aRustGalleryItemTypeImage()),
+) = GalleryMessageContent(body = body, formatted = formatted, itemtypes = itemTypes)
+
+internal fun aRustGalleryItemTypeImage(
+ content: ImageMessageContent = aRustImageMessageContent(),
+) = GalleryItemType.Image(content = content)
+
+internal fun aRustGalleryItemTypeAudio(
+ content: AudioMessageContent = aRustAudioMessageContent(),
+) = GalleryItemType.Audio(content = content)
+
+internal fun aRustGalleryItemTypeVideo(
+ content: VideoMessageContent = aRustVideoMessageContent(),
+) = GalleryItemType.Video(content = content)
+
+internal fun aRustGalleryItemTypeFile(
+ content: FileMessageContent = aRustFileMessageContent(),
+) = GalleryItemType.File(content = content)
+
+internal fun aRustGalleryItemTypeOther(
+ itemType: String = "m.unknown",
+ body: String = "unknown item",
+) = GalleryItemType.Other(itemtype = itemType, body = body)
+
+internal fun aRustImageMessageContent(
+ filename: String = "image.jpg",
+ caption: String? = null,
+ formattedCaption: FormattedBody? = null,
+ source: MediaSource = FakeFfiMediaSource("mxc://server/image"),
+ info: ImageInfo? = null,
+) = ImageMessageContent(
+ filename = filename,
+ caption = caption,
+ formattedCaption = formattedCaption,
+ source = source,
+ info = info,
+)
+
+internal fun aRustAudioMessageContent(
+ filename: String = "audio.mp3",
+ caption: String? = null,
+ formattedCaption: FormattedBody? = null,
+ source: MediaSource = FakeFfiMediaSource("mxc://server/audio"),
+ info: AudioInfo? = null,
+ audio: UnstableAudioDetailsContent? = null,
+ voice: UnstableVoiceContent? = null,
+) = AudioMessageContent(
+ filename = filename,
+ caption = caption,
+ formattedCaption = formattedCaption,
+ source = source,
+ info = info,
+ audio = audio,
+ voice = voice,
+)
+
+internal fun aRustVideoMessageContent(
+ filename: String = "video.mp4",
+ caption: String? = null,
+ formattedCaption: FormattedBody? = null,
+ source: MediaSource = FakeFfiMediaSource("mxc://server/video"),
+ info: VideoInfo? = null,
+) = VideoMessageContent(
+ filename = filename,
+ caption = caption,
+ formattedCaption = formattedCaption,
+ source = source,
+ info = info,
+)
+
+internal fun aRustFileMessageContent(
+ filename: String = "document.pdf",
+ caption: String? = null,
+ formattedCaption: FormattedBody? = null,
+ source: MediaSource = FakeFfiMediaSource("mxc://server/file"),
+ info: FileInfo? = null,
+) = FileMessageContent(
+ filename = filename,
+ caption = caption,
+ formattedCaption = formattedCaption,
+ source = source,
+ info = info,
+)
diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/fakes/FakeFfiMediaSource.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/fakes/FakeFfiMediaSource.kt
new file mode 100644
index 0000000000..7dbcb1d762
--- /dev/null
+++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/fakes/FakeFfiMediaSource.kt
@@ -0,0 +1,18 @@
+/*
+ * Copyright (c) 2026 Element Creations Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
+ * Please see LICENSE files in the repository root for full details.
+ */
+
+package io.element.android.libraries.matrix.impl.fixtures.fakes
+
+import org.matrix.rustcomponents.sdk.MediaSource
+import org.matrix.rustcomponents.sdk.NoHandle
+
+internal class FakeFfiMediaSource(
+ private val fakeUrl: String = "mxc://server/media",
+) : MediaSource(NoHandle) {
+ override fun url(): String = fakeUrl
+ override fun toJson(): String = """{"url":"$fakeUrl"}"""
+}
diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/keys/DefaultSecretGeneratorTest.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/keys/DefaultSecretGeneratorTest.kt
index 4bda511d42..b18933fb53 100644
--- a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/keys/DefaultSecretGeneratorTest.kt
+++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/keys/DefaultSecretGeneratorTest.kt
@@ -10,12 +10,10 @@ package io.element.android.libraries.matrix.impl.keys
import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.androidutils.crypto.ClientSecret
+import io.element.android.tests.testutils.robolectric.RobolectricTest
import org.junit.Test
-import org.junit.runner.RunWith
-import org.robolectric.RobolectricTestRunner
-@RunWith(RobolectricTestRunner::class)
-class DefaultSecretGeneratorTest {
+class DefaultSecretGeneratorTest : RobolectricTest() {
@Test
fun `check that generated passphrase has the expected length`() {
val secretGenerator = DefaultSecretGenerator()
diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/linknewdevice/RustLinkMobileHandlerTest.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/linknewdevice/RustLinkMobileHandlerTest.kt
index ad751cc86b..3d84e34f45 100644
--- a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/linknewdevice/RustLinkMobileHandlerTest.kt
+++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/linknewdevice/RustLinkMobileHandlerTest.kt
@@ -147,6 +147,32 @@ class RustLinkMobileHandlerTest {
}
}
+ @Test
+ fun `when start throws HumanQrGrantLoginException_Expired, the handler emits QrRotating step`() = runTest {
+ val completable = CompletableDeferred()
+ val handler = FakeFfiGrantLoginWithQrCodeHandler(
+ generateResult = {
+ completable.await()
+ throw HumanQrGrantLoginException.Expired("Expired")
+ }
+ )
+ val sut = createRustLinkMobileHandler(
+ handler,
+ )
+ sut.linkMobileStep.test {
+ val initialItem = awaitItem()
+ assertThat(initialItem).isEqualTo(LinkMobileStep.Uninitialized)
+ backgroundScope.launch {
+ sut.start()
+ }
+ runCurrent()
+ // generate returns, error is emitted
+ completable.complete(Unit)
+ val qrRotatingState = awaitItem()
+ assertThat(qrRotatingState).isEqualTo(LinkMobileStep.QrRotating)
+ }
+ }
+
private fun TestScope.createRustLinkMobileHandler(
handler: FakeFfiGrantLoginWithQrCodeHandler = FakeFfiGrantLoginWithQrCodeHandler(),
) = RustLinkMobileHandler(
diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/mapper/SessionKtTest.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/mapper/SessionKtTest.kt
index fb9ec6625b..6042f195f8 100644
--- a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/mapper/SessionKtTest.kt
+++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/mapper/SessionKtTest.kt
@@ -10,8 +10,8 @@ package io.element.android.libraries.matrix.impl.mapper
import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.matrix.api.auth.external.ExternalSession
+import io.element.android.libraries.matrix.api.paths.SessionPaths
import io.element.android.libraries.matrix.impl.fixtures.factories.aRustSession
-import io.element.android.libraries.matrix.impl.paths.SessionPaths
import io.element.android.libraries.matrix.test.A_DEVICE_ID
import io.element.android.libraries.matrix.test.A_HOMESERVER_URL
import io.element.android.libraries.matrix.test.A_HOMESERVER_URL_2
diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/permalink/DefaultMatrixToConverterTest.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/permalink/DefaultMatrixToConverterTest.kt
index dcd2c4594c..97b51c0026 100644
--- a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/permalink/DefaultMatrixToConverterTest.kt
+++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/permalink/DefaultMatrixToConverterTest.kt
@@ -10,12 +10,10 @@ package io.element.android.libraries.matrix.impl.permalink
import android.net.Uri
import com.google.common.truth.Truth.assertThat
+import io.element.android.tests.testutils.robolectric.RobolectricTest
import org.junit.Test
-import org.junit.runner.RunWith
-import org.robolectric.RobolectricTestRunner
-@RunWith(RobolectricTestRunner::class)
-class DefaultMatrixToConverterTest {
+class DefaultMatrixToConverterTest : RobolectricTest() {
@Test
fun `converting a matrix-to url does nothing`() {
val url = Uri.parse("https://matrix.to/#/#element-android:matrix.org")
diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/room/FakeTimelineEventFilterFactory.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/room/FakeTimelineEventFilterFactory.kt
index f35746d8d3..713e04d239 100644
--- a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/room/FakeTimelineEventFilterFactory.kt
+++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/room/FakeTimelineEventFilterFactory.kt
@@ -9,11 +9,12 @@
package io.element.android.libraries.matrix.impl.room
import io.element.android.libraries.matrix.api.room.StateEventType
+import io.element.android.libraries.matrix.api.room.join.JoinRule
import io.element.android.libraries.matrix.impl.fixtures.fakes.FakeFfiTimelineEventFilter
import org.matrix.rustcomponents.sdk.TimelineEventFilter
class FakeTimelineEventFilterFactory : TimelineEventFilterFactory {
- override fun create(listStateEventType: List