-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..dc9513a239
--- /dev/null
+++ b/libraries/compound/src/main/res/drawable/ic_compound_crop.xml
@@ -0,0 +1,12 @@
+
+
+
+
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/dateformatter/impl/src/main/res/values-ca/translations.xml b/libraries/dateformatter/impl/src/main/res/values-ca/translations.xml
new file mode 100644
index 0000000000..491793c728
--- /dev/null
+++ b/libraries/dateformatter/impl/src/main/res/values-ca/translations.xml
@@ -0,0 +1,5 @@
+
+
+ "%1$s a les %2$s"
+ "Aquest mes"
+
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..ff49bab06f 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
@@ -10,6 +10,7 @@ package io.element.android.libraries.designsystem.atomic.atoms
import androidx.compose.foundation.background
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 +30,32 @@ 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,
) {
- Box(
- modifier = modifier
- .semantics {
+ when {
+ !isVisible -> Spacer(modifier = modifier.size(size))
+ count != null && count >= 1 -> CounterAtom(
+ count = count.toInt(),
+ modifier = modifier.semantics {
contentDescription?.let { this.contentDescription = it }
- }
- .size(size)
- .clip(CircleShape)
- .background(if (isVisible) color else Color.Transparent)
- )
+ },
+ 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),
+ )
+ }
}
@PreviewsDayNight
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/theme/components/ModalBottomSheet.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/ModalBottomSheet.kt
index 689cba727f..ec5f7ce456 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
@@ -24,6 +24,8 @@ import androidx.compose.material3.SheetState
import androidx.compose.material3.contentColorFor
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
+import androidx.compose.runtime.movableContentOf
+import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Shape
@@ -88,14 +90,15 @@ fun ModalBottomSheet(
dragHandle = dragHandle,
contentWindowInsets = contentWindowInsets,
) {
+ val movableContent = remember { movableContentOf { content() } }
if (scrollable) {
Column(
modifier = Modifier.verticalScroll(rememberScrollState()),
) {
- content()
+ movableContent()
}
} else {
- content()
+ movableContent()
}
}
}
diff --git a/libraries/encrypted-db/src/main/kotlin/io/element/encrypteddb/SqlCipherDriverFactory.kt b/libraries/encrypted-db/src/main/kotlin/io/element/encrypteddb/SqlCipherDriverFactory.kt
index e4ac1a949f..84ab2d2a18 100644
--- a/libraries/encrypted-db/src/main/kotlin/io/element/encrypteddb/SqlCipherDriverFactory.kt
+++ b/libraries/encrypted-db/src/main/kotlin/io/element/encrypteddb/SqlCipherDriverFactory.kt
@@ -9,30 +9,54 @@
package io.element.encrypteddb
import android.content.Context
+import androidx.sqlite.db.SupportSQLiteDatabase
import app.cash.sqldelight.db.QueryResult
import app.cash.sqldelight.db.SqlDriver
import app.cash.sqldelight.db.SqlSchema
import app.cash.sqldelight.driver.android.AndroidSqliteDriver
-import io.element.encrypteddb.passphrase.PassphraseProvider
+import io.element.android.libraries.androidutils.crypto.ClientSecret
+import io.element.encrypteddb.passphrase.DatabaseSecretProvider
import net.zetetic.database.sqlcipher.SupportOpenHelperFactory
/**
* Creates an encrypted version of the [SqlDriver] using SQLCipher's [SupportOpenHelperFactory].
- * @param passphraseProvider Provides the passphrase needed to use the SQLite database with SQLCipher.
+ * @param databaseSecretProvider Provides the passphrase needed to use the SQLite database with SQLCipher.
*/
class SqlCipherDriverFactory(
- private val passphraseProvider: PassphraseProvider,
+ private val databaseSecretProvider: DatabaseSecretProvider,
) {
+ companion object {
+ init {
+ System.loadLibrary("sqlcipher")
+ }
+ }
+
/**
* Returns a valid [SqlDriver] with SQLCipher support.
* @param schema The SQLite DB schema.
* @param name The name of the database to create.
* @param context Android [Context], used to instantiate the driver.
+ * @param onUpgradeCallback Optional callback to handle database upgrades, which will be called in the [AndroidSqliteDriver.Callback.onUpgrade] method.
*/
- fun create(schema: SqlSchema>, name: String, context: Context): SqlDriver {
- System.loadLibrary("sqlcipher")
- val passphrase = passphraseProvider.getPassphrase()
- val factory = SupportOpenHelperFactory(passphrase)
- return AndroidSqliteDriver(schema = schema, context = context, name = name, factory = factory)
+ fun create(
+ schema: SqlSchema>,
+ name: String,
+ context: Context,
+ onUpgradeCallback: ((driver: SupportSQLiteDatabase, oldVersion: Int, newVersion: Int) -> Unit)? = null,
+ ): SqlDriver {
+ val key = when (val secret = databaseSecretProvider.getSecret()) {
+ // For a raw key, we need the blob representation (`x'...'`) as bytes
+ is ClientSecret.RawKey -> secret.formattedAsString().toByteArray()
+ // For a passphrase, we need the bytes for the hex string representation
+ is ClientSecret.Passphrase -> secret.formattedAsString().hexToByteArray()
+ }
+ val factory = SupportOpenHelperFactory(key)
+ return AndroidSqliteDriver(schema = schema, context = context, name = name, factory = factory, callback = object : AndroidSqliteDriver.Callback(
+ schema
+ ) {
+ override fun onUpgrade(db: SupportSQLiteDatabase, oldVersion: Int, newVersion: Int) {
+ onUpgradeCallback?.invoke(db, oldVersion, newVersion)
+ }
+ })
}
}
diff --git a/libraries/encrypted-db/src/main/kotlin/io/element/encrypteddb/passphrase/PassphraseProvider.kt b/libraries/encrypted-db/src/main/kotlin/io/element/encrypteddb/passphrase/DatabaseSecretProvider.kt
similarity index 51%
rename from libraries/encrypted-db/src/main/kotlin/io/element/encrypteddb/passphrase/PassphraseProvider.kt
rename to libraries/encrypted-db/src/main/kotlin/io/element/encrypteddb/passphrase/DatabaseSecretProvider.kt
index a72dc73596..140a5d8de2 100644
--- a/libraries/encrypted-db/src/main/kotlin/io/element/encrypteddb/passphrase/PassphraseProvider.kt
+++ b/libraries/encrypted-db/src/main/kotlin/io/element/encrypteddb/passphrase/DatabaseSecretProvider.kt
@@ -8,12 +8,19 @@
package io.element.encrypteddb.passphrase
+import io.element.android.libraries.androidutils.crypto.ClientSecret
+
/**
* An abstraction to implement secure providers for SQLCipher passphrases.
*/
-interface PassphraseProvider {
+interface DatabaseSecretProvider {
/**
- * Returns a passphrase for SQLCipher in [ByteArray] format.
+ * Returns a secret for SQLCipher.
*/
- fun getPassphrase(): ByteArray
+ fun getSecret(): ClientSecret
+
+ /**
+ * Resets the passphrase, for example by deleting the persisted secret. Returns `true` if the reset was successful, `false` otherwise.
+ */
+ fun reset(): Boolean
}
diff --git a/libraries/encrypted-db/src/main/kotlin/io/element/encrypteddb/passphrase/RandomSecretPassphraseProvider.kt b/libraries/encrypted-db/src/main/kotlin/io/element/encrypteddb/passphrase/RandomDatabaseSecretProvider.kt
similarity index 56%
rename from libraries/encrypted-db/src/main/kotlin/io/element/encrypteddb/passphrase/RandomSecretPassphraseProvider.kt
rename to libraries/encrypted-db/src/main/kotlin/io/element/encrypteddb/passphrase/RandomDatabaseSecretProvider.kt
index fe78a4a8da..246a65da07 100644
--- a/libraries/encrypted-db/src/main/kotlin/io/element/encrypteddb/passphrase/RandomSecretPassphraseProvider.kt
+++ b/libraries/encrypted-db/src/main/kotlin/io/element/encrypteddb/passphrase/RandomDatabaseSecretProvider.kt
@@ -9,35 +9,41 @@
package io.element.encrypteddb.passphrase
import android.content.Context
+import io.element.android.libraries.androidutils.crypto.ClientSecret
import io.element.encrypteddb.crypto.EncryptedFile
import java.io.File
import java.security.SecureRandom
/**
- * Provides a secure passphrase for SQLCipher by generating a random secret and storing it into an [EncryptedFile].
+ * Provides a secure secret for SQLCipher by generating a random secret and storing it into an [EncryptedFile].
* @param context Android [Context], used by [EncryptedFile] for cryptographic operations.
* @param file Destination file where the key will be stored.
- * @param secretSize Length of the generated secret.
+ * @param secretSizeBytes Length of the generated secret.
*/
-class RandomSecretPassphraseProvider(
+class RandomDatabaseSecretProvider(
private val context: Context,
private val file: File,
- private val secretSize: Int = 256,
-) : PassphraseProvider {
- override fun getPassphrase(): ByteArray {
+ private val secretSizeBytes: Int = 32,
+) : DatabaseSecretProvider {
+ override fun getSecret(): ClientSecret {
val encryptedFile = EncryptedFile(context, file)
- return if (!file.exists()) {
+ val bytes = if (!file.exists()) {
val secret = generateSecret()
encryptedFile.openFileOutput().use { it.write(secret) }
secret
} else {
encryptedFile.openFileInput().use { it.readBytes() }
}
+ return ClientSecret.fromRawBytes(bytes)
+ }
+
+ override fun reset(): Boolean {
+ return file.delete()
}
private fun generateSecret(): ByteArray {
- val buffer = ByteArray(size = secretSize)
- SecureRandom().nextBytes(buffer)
- return buffer
+ // Generate a random secret of the specified size using a secure random generator.
+ return ByteArray(size = secretSizeBytes)
+ .also { SecureRandom().nextBytes(it) }
}
}
diff --git a/libraries/encrypted-db/src/main/kotlin/io/element/encrypteddb/utils/ReplaceDatabaseKey.kt b/libraries/encrypted-db/src/main/kotlin/io/element/encrypteddb/utils/ReplaceDatabaseKey.kt
new file mode 100644
index 0000000000..fafb18794b
--- /dev/null
+++ b/libraries/encrypted-db/src/main/kotlin/io/element/encrypteddb/utils/ReplaceDatabaseKey.kt
@@ -0,0 +1,45 @@
+/*
+ * 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.encrypteddb.utils
+
+import androidx.sqlite.db.SupportSQLiteDatabase
+import io.element.encrypteddb.passphrase.RandomDatabaseSecretProvider
+import timber.log.Timber
+
+/**
+ * A utility class to replace the encryption key of an existing SQLCipher database.
+ * This is used during database migrations when we want to change the encryption key.
+ *
+ * @param databaseSecretProvider The provider for generating new secrets.
+ */
+class ReplaceDatabaseKey(
+ private val databaseSecretProvider: RandomDatabaseSecretProvider
+) {
+ fun replaceKey(name: String, database: SupportSQLiteDatabase) {
+ Timber.d("Re-keying database $name")
+ // Reset the passphrase provider to generate a new passphrase
+ databaseSecretProvider.reset()
+
+ // Get the new secret and convert it to the format expected by SQLCipher
+ val newSecret = databaseSecretProvider.getSecret()
+ val key = newSecret.formattedAsString()
+
+ // Use the PRAGMA rekey command to change the encryption key of the database
+ database.query("PRAGMA rekey = \"$key\";").close()
+
+ // Verify that the database can be accessed with the new key by running a simple query
+ val result = database.query("select count(*) from sqlite_master").use { cursor ->
+ if (cursor.moveToNext()) cursor.getLong(0) else -1L
+ }
+ if (result >= 0) {
+ Timber.d("Re-keying database $name completed")
+ } else {
+ Timber.e("Re-keying database $name didn't work as expected.")
+ }
+ }
+}
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 d234e7b239..e8a462da16 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
@@ -93,8 +93,8 @@ class DefaultRoomLatestEventFormatter(
message.prefixIfNeeded(senderDisambiguatedDisplayName, isDmRoom, isOutgoing)
}
is StickerContent -> {
- val message = sp.getString(CommonStrings.common_sticker) + " (" + content.bestDescription + ")"
- message.prefixIfNeeded(senderDisambiguatedDisplayName, isDmRoom, isOutgoing)
+ content.bestDescription.prefixWith(sp.getString(CommonStrings.common_sticker))
+ .prefixIfNeeded(senderDisambiguatedDisplayName, isDmRoom, isOutgoing)
}
is UnableToDecryptContent -> {
val message = sp.getString(CommonStrings.common_waiting_for_decryption_key)
@@ -110,8 +110,8 @@ class DefaultRoomLatestEventFormatter(
stateContentFormatter.format(content, senderDisambiguatedDisplayName, isOutgoing, RenderingMode.RoomList)
}
is PollContent -> {
- val message = sp.getString(CommonStrings.common_poll_summary, content.question)
- message.prefixIfNeeded(senderDisambiguatedDisplayName, isDmRoom, isOutgoing)
+ content.question.prefixWith(sp.getString(CommonStrings.common_poll_summary_prefix))
+ .prefixIfNeeded(senderDisambiguatedDisplayName, isDmRoom, isOutgoing)
}
is FailedToParseMessageLikeContent, is FailedToParseStateContent, is UnknownContent -> {
val message = sp.getString(CommonStrings.common_unsupported_event)
diff --git a/libraries/eventformatter/impl/src/main/kotlin/io/element/android/libraries/eventformatter/impl/PrefixWith.kt b/libraries/eventformatter/impl/src/main/kotlin/io/element/android/libraries/eventformatter/impl/PrefixWith.kt
index 51fdccf256..1a04893e07 100644
--- a/libraries/eventformatter/impl/src/main/kotlin/io/element/android/libraries/eventformatter/impl/PrefixWith.kt
+++ b/libraries/eventformatter/impl/src/main/kotlin/io/element/android/libraries/eventformatter/impl/PrefixWith.kt
@@ -20,6 +20,10 @@ internal fun CharSequence.prefixWith(prefix: String): AnnotatedString {
append(prefix)
}
append(": ")
- append(this@prefixWith)
+ if (this@prefixWith is AnnotatedString) {
+ append(this@prefixWith)
+ } else {
+ append(this@prefixWith.toString())
+ }
}
}
diff --git a/libraries/eventformatter/impl/src/main/res/values-ca/translations.xml b/libraries/eventformatter/impl/src/main/res/values-ca/translations.xml
new file mode 100644
index 0000000000..2caa14e8a3
--- /dev/null
+++ b/libraries/eventformatter/impl/src/main/res/values-ca/translations.xml
@@ -0,0 +1,73 @@
+
+
+ "(la foto també ha canviat)"
+ "%1$s ha canviat la seva foto"
+ "Has canviat la teva foto"
+ "%1$s ha baixat a membre"
+ "%1$s ha baixat a moderador/a"
+ "%1$s han canviat el seu nom de visualització de %2$s a %3$s"
+ "Has canviat el teu nom de visualització de %1$s a %2$s"
+ "%1$s ha eliminat el seu nom de visualització (era %2$s)"
+ "Has eliminat el teu nom de visualització (era %1$s)"
+ "%1$s ha definit el seu nom de visualització a %2$s"
+ "Has definit el teu nom de visualització a %1$s"
+ "%1$s ha ascendit a administrador/a"
+ "%1$s ha ascendit a moderador/a"
+ "%1$s ha canviat la foto de la sala"
+ "Has canviat la foto de la sala"
+ "%1$s ha eliminat la foto de la sala"
+ "Has eliminat la foto de la sala"
+ "%1$s ha bandejat %2$s"
+ "Has bandejat %1$s"
+ "Has bandejat %1$s: %2$s"
+ "%1$s ha bandejat %2$s: %3$s"
+ "%1$s ha creat la sala"
+ "Has creat la sala"
+ "%1$s a convidat a %2$s"
+ "%1$s ha acceptat la invitació"
+ "Has acceptat la invitació"
+ "Has convidat a %1$s"
+ "%1$s t\'ha convidat"
+ "%1$s s\'ha unit a la sala"
+ "T\'has unit a la sala"
+ "%1$s ha sol·licitat unir-se"
+ "%1$s ha concedit l\'accés a %2$s"
+ "Has permès %1$s unir-se"
+ "Has sol·licitat unir-te"
+ "%1$s ha rebutjat la sol·licitud d\'unió a %2$s"
+ "Has rebutjat la sol·licitud d\'unió a %1$s"
+ "%1$s ha rebutjat la teva sol·licitud d\'unió"
+ "%1$s ja no es vol unir"
+ "Has cancel·lat la sol·licitud per unir-te"
+ "%1$s ha sortit de la sala"
+ "Has sortit de la sala"
+ "%1$s ha canviat el nom de la sala a: %2$s"
+ "Has canviat el nom de la sala a: %1$s"
+ "%1$s ha eliminat el nom de la sala"
+ "Has eliminat el nom de la sala"
+ "%1$s no ha fet canvis"
+ "No s\'han fet canvis"
+ "%1$s ha canviat els missatges fixats"
+ "Has canviat els missatges fixats"
+ "%1$s ha fixat un missatge"
+ "Has fixat un missatge"
+ "%1$s ha deixat de fixar un missatge"
+ "Has deixat de fixar un missatge"
+ "%1$s ha rebutjat la invitació"
+ "Has rebutjat la invitació"
+ "%1$s ha eliminat %2$s"
+ "Has eliminat %1$s"
+ "Has eliminat %1$s: %2$s"
+ "%1$s ha eliminat %2$s: %3$s"
+ "%1$s ha convidat a %2$s a la sala"
+ "Has convidat a %1$s a la sala"
+ "%1$s ha rebutjat la invitació a la sala de %2$s"
+ "Has rebutjat la invitació a la sala de %1$s"
+ "%1$s ha canviat el tema a: %2$s"
+ "Has canviat el tema a: %1$s"
+ "%1$s ha eliminat el tema de la sala"
+ "Has eliminat el tema de la sala"
+ "%1$s ha readmès %2$s"
+ "Has readmès %1$s"
+ "%1$s ha fet un canvi desconegut al seu tipus d\'usuari"
+
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 e0613ed008..2e55fb0999 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
@@ -10,6 +10,7 @@ package io.element.android.libraries.eventformatter.impl
import android.content.Context
import androidx.compose.ui.text.AnnotatedString
+import androidx.compose.ui.text.font.FontWeight
import com.google.common.truth.Truth.assertThat
import com.google.common.truth.Truth.assertWithMessage
import io.element.android.libraries.matrix.api.core.UserId
@@ -103,7 +104,14 @@ class DefaultRoomLatestEventFormatterTest {
val info = ImageInfo(null, null, null, null, null, null, null)
val message = createLatestEvent(false, null, aStickerContent(body, info, aMediaSource(url = "url")))
val result = formatter.format(message, false)
- val expectedBody = someoneElseId.value + ": Sticker (a sticker body)"
+ val expectedBody = someoneElseId.value + ": Sticker: a sticker body"
+ // Check we have formatting
+ assertThat(result is AnnotatedString).isTrue()
+ // And there is a bold span for the 'Sticker' part
+ val boldSpanStyle = (result as AnnotatedString).spanStyles.lastOrNull { it.item.fontWeight == FontWeight.Bold }
+ assertThat(boldSpanStyle).isNotNull()
+ val spanStart = someoneElseId.value.length + 2
+ assertThat(boldSpanStyle!!.start..boldSpanStyle.end).isEqualTo(spanStart..spanStart + 7)
assertThat(result.toString()).isEqualTo(expectedBody)
}
@@ -909,10 +917,18 @@ class DefaultRoomLatestEventFormatterTest {
val pollContent = aPollContent()
val mineContentEvent = createLatestEvent(sentByYou = true, senderDisplayName = "Alice", content = pollContent)
- assertThat(formatter.format(mineContentEvent, true)).isEqualTo("Poll: Do you like polls?")
+ assertThat(formatter.format(mineContentEvent, true).toString()).isEqualTo("Poll: Do you like polls?")
val contentEvent = createLatestEvent(sentByYou = false, senderDisplayName = "Bob", content = pollContent)
- assertThat(formatter.format(contentEvent, true)).isEqualTo("Poll: Do you like polls?")
+ assertThat(formatter.format(contentEvent, true).toString()).isEqualTo("Poll: Do you like polls?")
+
+ val result = formatter.format(contentEvent, true)
+ // Check we have formatting
+ assertThat(result is AnnotatedString).isTrue()
+ // And there is a bold span for the 'Poll' part
+ val boldSpanStyle = (result as AnnotatedString).spanStyles.lastOrNull { it.item.fontWeight == FontWeight.Bold }
+ assertThat(boldSpanStyle).isNotNull()
+ assertThat(boldSpanStyle!!.start..boldSpanStyle.end).isEqualTo(0..4)
}
@Test
@@ -925,6 +941,15 @@ class DefaultRoomLatestEventFormatterTest {
val contentEvent = createLatestEvent(sentByYou = false, senderDisplayName = "Bob", content = pollContent)
assertThat(formatter.format(contentEvent, false).toString()).isEqualTo("Bob: Poll: Do you like polls?")
+
+ val result = formatter.format(contentEvent, false)
+ // Check we have formatting
+ assertThat(result is AnnotatedString).isTrue()
+ // And there is a bold span for the 'Poll' part
+ val boldSpanStyle = (result as AnnotatedString).spanStyles.lastOrNull { it.item.fontWeight == FontWeight.Bold }
+ assertThat(boldSpanStyle).isNotNull()
+ val spanStart = "Bob".length + 2
+ assertThat(boldSpanStyle!!.start..boldSpanStyle.end).isEqualTo(spanStart..spanStart + 4)
}
// endregion
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..0127e41c99 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,13 +108,6 @@ 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.",
- defaultValue = { false },
- isFinished = false,
- ),
SlashCommand(
key = "feature.slash_command",
title = "Parse slash commands in the message composer",
@@ -137,4 +130,11 @@ 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,
+ ),
}
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 bd7b1399e1..836459f8c7 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
@@ -221,6 +221,11 @@ interface MatrixClient {
*/
suspend fun performDatabaseVacuum(): Result
+ /**
+ * Returns the URL of the map style configured on the server, if any.
+ */
+ suspend fun getMapStyleUrl(): Result
+
/**
* Resets the cached client `well-known` config by the SDK.
*/
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 0c261cdd1a..1947729c7f 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
@@ -18,6 +18,7 @@ sealed interface LinkMobileStep {
data object Uninitialized : LinkMobileStep
data object Starting : LinkMobileStep
data class QrReady(val data: String) : LinkMobileStep
+ data object QrRotating : LinkMobileStep
data class WaitingForAuth(val verificationUri: String) : LinkMobileStep
data class QrScanned(val checkCodeSender: CheckCodeSender) : LinkMobileStep
data class Error(val errorType: ErrorType) : LinkMobileStep
diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/pusher/SetHttpPusherData.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/pusher/SetHttpPusherData.kt
index 8a557f4b70..97a3cabd20 100644
--- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/pusher/SetHttpPusherData.kt
+++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/pusher/SetHttpPusherData.kt
@@ -17,4 +17,5 @@ data class SetHttpPusherData(
val profileTag: String?,
val lang: String,
val defaultPayload: String,
+ val append: Boolean,
)
diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/RoomInfo.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/RoomInfo.kt
index b9ed8d61b1..e590976cab 100644
--- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/RoomInfo.kt
+++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/RoomInfo.kt
@@ -79,6 +79,7 @@ data class RoomInfo(
val privilegedCreatorRole: Boolean,
val isLowPriority: Boolean,
val activeCallIntentConsensus: CallIntentConsensus,
+ val fullyReadEventId: EventId?,
) {
val aliases: List
get() = listOfNotNull(canonicalAlias) + alternativeAliases
diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustClientSessionDelegate.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustClientSessionDelegate.kt
index 690995ba77..a5c69bf831 100644
--- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustClientSessionDelegate.kt
+++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustClientSessionDelegate.kt
@@ -8,7 +8,7 @@
package io.element.android.libraries.matrix.impl
-import io.element.android.libraries.core.coroutine.CoroutineDispatchers
+import io.element.android.libraries.core.extensions.runCatchingExceptions
import io.element.android.libraries.core.log.logger.LoggerTag
import io.element.android.libraries.matrix.impl.core.SdkBackgroundTaskError
import io.element.android.libraries.matrix.impl.mapper.toSessionData
@@ -19,6 +19,7 @@ import io.element.android.services.analytics.api.AnalyticsService
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
+import kotlinx.coroutines.runBlocking
import org.matrix.rustcomponents.sdk.ClientDelegate
import org.matrix.rustcomponents.sdk.ClientSessionDelegate
import org.matrix.rustcomponents.sdk.Session
@@ -41,14 +42,10 @@ class RustClientSessionDelegate(
private val sessionStore: SessionStore,
private val appCoroutineScope: CoroutineScope,
private val analyticsService: AnalyticsService,
- coroutineDispatchers: CoroutineDispatchers,
) : ClientSessionDelegate, ClientDelegate {
// Used to ensure several calls to `didReceiveAuthError` don't trigger multiple logouts
private val isLoggingOut = AtomicBoolean(false)
- // To make sure only one coroutine affecting the token persistence can run at a time
- private val updateTokensDispatcher = coroutineDispatchers.io.limitedParallelism(1)
-
// This Client needs to be set up as soon as possible so `didReceiveAuthError` can work properly.
private var client: WeakReference = WeakReference(null)
@@ -66,11 +63,24 @@ class RustClientSessionDelegate(
this.client.clear()
}
+ // This always runs on a background thread, so we *can* do blocking calls here, although we should avoid doing heavy work
override fun saveSessionInKeychain(session: Session) {
- appCoroutineScope.launch(updateTokensDispatcher) {
- val existingData = sessionStore.getSession(session.userId) ?: return@launch
+ Timber.tag(loggerTag.value).i("Saving new session info for user ${session.userId} after a token refresh")
+ runCatchingExceptions {
+ val existingData = runBlocking { sessionStore.getSession(session.userId) } ?: return
+
+ if (existingData.accessToken == session.accessToken) {
+ Timber.tag(loggerTag.value).e("Access token is the same as the one already stored, this should not happen after a token refresh!")
+ return
+ }
+
+ if (existingData.refreshToken == session.refreshToken) {
+ Timber.tag(loggerTag.value).e("Refresh token is the same as the one already stored, this should not happen after a token refresh!")
+ return
+ }
+
val (anonymizedAccessToken, anonymizedRefreshToken) = session.anonymizedTokens()
- Timber.tag(loggerTag.value).d(
+ Timber.tag(loggerTag.value).i(
"Saving new session data with token: access token '$anonymizedAccessToken' and refresh token '$anonymizedRefreshToken'. " +
"Was token valid: ${existingData.isTokenValid}"
)
@@ -80,28 +90,27 @@ class RustClientSessionDelegate(
passphrase = existingData.passphrase,
sessionPaths = existingData.getSessionPaths(),
)
- sessionStore.updateData(newData)
- Timber.tag(loggerTag.value).d("Saved new session data with access token: '$anonymizedAccessToken'.")
- }.invokeOnCompletion {
- if (it != null) {
- Timber.tag(loggerTag.value).e(it, "Failed to save new session data.")
- }
+ runBlocking { sessionStore.updateData(newData) }
+ Timber.tag(loggerTag.value).i("Saved new session data.")
+ }.onFailure {
+ Timber.tag(loggerTag.value).e(it, "Failed to save new session data.")
}
}
+ // This always runs on a background thread, so we *can* do blocking calls here, although we should avoid doing heavy work
override fun didReceiveAuthError(isSoftLogout: Boolean) {
- Timber.tag(loggerTag.value).w("didReceiveAuthError(isSoftLogout=$isSoftLogout)")
- if (isLoggingOut.getAndSet(true).not()) {
- Timber.tag(loggerTag.value).v("didReceiveAuthError -> do the cleanup")
- // TODO handle isSoftLogout parameter.
- appCoroutineScope.launch(updateTokensDispatcher) {
+ runCatchingExceptions {
+ Timber.tag(loggerTag.value).w("didReceiveAuthError(isSoftLogout=$isSoftLogout)")
+ if (isLoggingOut.getAndSet(true).not()) {
+ Timber.tag(loggerTag.value).v("didReceiveAuthError -> do the cleanup")
+ // TODO handle isSoftLogout parameter.
val currentClient = client.get()
if (currentClient == null) {
Timber.tag(loggerTag.value).w("didReceiveAuthError -> no client, exiting")
isLoggingOut.set(false)
- return@launch
+ return
}
- val existingData = sessionStore.getSession(currentClient.sessionId.value)
+ val existingData = runBlocking { sessionStore.getSession(currentClient.sessionId.value) }
val (anonymizedAccessToken, anonymizedRefreshToken) = existingData.anonymizedTokens()
Timber.tag(loggerTag.value).d(
"Removing session data with access token '$anonymizedAccessToken' " +
@@ -110,19 +119,17 @@ class RustClientSessionDelegate(
if (existingData != null) {
// Set isTokenValid to false
val newData = existingData.copy(isTokenValid = false)
- sessionStore.updateData(newData)
+ runBlocking { sessionStore.updateData(newData) }
Timber.tag(loggerTag.value).d("Invalidated session data with access token: '$anonymizedAccessToken'.")
} else {
Timber.tag(loggerTag.value).d("No session data found.")
}
- currentClient.logout(userInitiated = false, ignoreSdkError = true)
- }.invokeOnCompletion {
- if (it != null) {
- Timber.tag(loggerTag.value).e(it, "Failed to remove session data.")
- }
+ appCoroutineScope.launch { currentClient.logout(userInitiated = false, ignoreSdkError = true) }
+ } else {
+ Timber.tag(loggerTag.value).v("didReceiveAuthError -> already cleaning up")
}
- } else {
- Timber.tag(loggerTag.value).v("didReceiveAuthError -> already cleaning up")
+ }.onFailure {
+ Timber.tag(loggerTag.value).e(it, "Failed to remove session data.")
}
}
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 05e52605dc..bec20aedf2 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
@@ -808,6 +808,12 @@ class RustMatrixClient(
}
}
+ override suspend fun getMapStyleUrl(): Result = withContext(sessionDispatcher) {
+ runCatchingExceptions {
+ innerClient.tileServer()?.mapStyleUrl
+ }
+ }
+
override suspend fun resetWellKnownConfig(): Result {
return runCatchingExceptions {
Timber.d("Resetting well-known config for session $sessionId")
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 9a573a59c1..4a91462399 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
@@ -9,6 +9,7 @@
package io.element.android.libraries.matrix.impl
import dev.zacsweers.metro.Inject
+import io.element.android.libraries.androidutils.crypto.ClientSecret
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.core.data.ByteUnit
import io.element.android.libraries.core.data.megaBytes
@@ -72,13 +73,14 @@ class RustMatrixClientFactory(
sessionStore = sessionStore,
appCoroutineScope = appCoroutineScope,
analyticsService = analyticsService,
- coroutineDispatchers = coroutineDispatchers
)
suspend fun create(sessionData: SessionData): RustMatrixClient = withContext(coroutineDispatchers.io) {
+ // This secret is called 'passphrase' for historical reasons, but it can be a raw key or an actual passphrase
+ val clientSecret = sessionData.passphrase?.let(ClientSecret::fromString)
val client = getBaseClientBuilder(
sessionPaths = sessionData.getSessionPaths(),
- passphrase = sessionData.passphrase,
+ clientSecret = clientSecret,
slidingSyncType = ClientBuilderSlidingSync.Restored,
)
.homeserverUrl(sessionData.homeserverUrl)
@@ -132,19 +134,19 @@ class RustMatrixClientFactory(
analyticsService = analyticsService,
workManagerScheduler = workManagerScheduler,
).also {
- Timber.tag(it.toString()).d("Creating Client with access token '$anonymizedAccessToken' and refresh token '$anonymizedRefreshToken'")
+ Timber.tag("RustMatrixClient").i("Creating Client with access token '$anonymizedAccessToken' and refresh token '$anonymizedRefreshToken'")
}
}
internal suspend fun getBaseClientBuilder(
sessionPaths: SessionPaths,
- passphrase: String?,
+ clientSecret: ClientSecret?,
slidingSyncType: ClientBuilderSlidingSync,
): ClientBuilder {
return clientBuilderProvider.provide()
.run {
sqliteStoreBuilderProvider.provide(sessionPaths)
- .passphrase(passphrase)
+ .secret(clientSecret)
.setupClientBuilder(this)
}
.setSessionDelegate(sessionDelegate)
diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/auth/AuthenticationException.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/auth/AuthenticationException.kt
index 20dbf76a31..fac5227f6a 100644
--- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/auth/AuthenticationException.kt
+++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/auth/AuthenticationException.kt
@@ -28,6 +28,7 @@ fun Throwable.mapAuthenticationException(): AuthenticationException {
}
is ClientBuildException.WellKnownLookupFailed -> AuthenticationException.Generic(message)
is ClientBuildException.EventCache -> AuthenticationException.Generic(message)
+ is ClientBuildException.InvalidRawKey -> AuthenticationException.Generic(message)
}
is OAuthException -> when (this) {
is OAuthException.Generic -> AuthenticationException.OAuth(message)
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 692edf79ae..c44f7ffae7 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
@@ -12,6 +12,7 @@ import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesBinding
import dev.zacsweers.metro.SingleIn
import io.element.android.features.enterprise.api.EnterpriseService
+import io.element.android.libraries.androidutils.crypto.ClientSecret
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.core.extensions.mapFailure
import io.element.android.libraries.core.extensions.runCatchingExceptions
@@ -35,7 +36,7 @@ import io.element.android.libraries.matrix.impl.auth.qrlogin.QrErrorMapper
import io.element.android.libraries.matrix.impl.auth.qrlogin.SdkQrCodeLoginData
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.PassphraseGenerator
+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
@@ -65,7 +66,7 @@ class RustMatrixAuthenticationService(
private val coroutineDispatchers: CoroutineDispatchers,
private val sessionStore: SessionStore,
private val rustMatrixClientFactory: RustMatrixClientFactory,
- private val passphraseGenerator: PassphraseGenerator,
+ private val secretGenerator: SecretGenerator,
private val oAuthConfigurationProvider: OAuthConfigurationProvider,
private val enterpriseService: EnterpriseService,
) : MatrixAuthenticationService {
@@ -74,7 +75,7 @@ class RustMatrixAuthenticationService(
// Passphrase which will be used for new sessions. Existing sessions will use the passphrase
// stored in the SessionData.
- private val pendingPassphrase = getDatabasePassphrase()
+ private val pendingKey by lazy { getDatabaseKey() }
// Need to keep a copy of the current session path to eventually delete it.
// Ideally it would be possible to get the sessionPath from the Client to avoid doing this.
@@ -115,12 +116,9 @@ class RustMatrixAuthenticationService(
}
}
- private fun getDatabasePassphrase(): String? {
- val passphrase = passphraseGenerator.generatePassphrase()
- if (passphrase != null) {
- Timber.w("New sessions will be encrypted with a passphrase")
- }
- return passphrase
+ private fun getDatabaseKey(): ClientSecret {
+ Timber.d("New sessions will be encrypted with a raw key")
+ return secretGenerator.generateKey()
}
override suspend fun setHomeserver(homeserver: String): Result =
@@ -159,7 +157,7 @@ class RustMatrixAuthenticationService(
.toSessionData(
isTokenValid = true,
loginType = LoginType.PASSWORD,
- passphrase = pendingPassphrase,
+ passphrase = pendingKey.formattedAsString(),
sessionPaths = currentSessionPaths,
)
val matrixClient = rustMatrixClientFactory.create(client)
@@ -231,7 +229,7 @@ class RustMatrixAuthenticationService(
val sessionData = externalSession.toSessionData(
isTokenValid = true,
loginType = LoginType.PASSWORD,
- passphrase = pendingPassphrase,
+ passphrase = pendingKey.formattedAsString(),
sessionPaths = currentSessionPaths,
)
@@ -324,7 +322,7 @@ class RustMatrixAuthenticationService(
val sessionData = client.session().toSessionData(
isTokenValid = true,
loginType = LoginType.OIDC,
- passphrase = pendingPassphrase,
+ passphrase = pendingKey.formattedAsString(),
sessionPaths = currentSessionPaths,
)
val matrixClient = rustMatrixClientFactory.create(client)
@@ -389,7 +387,7 @@ class RustMatrixAuthenticationService(
.toSessionData(
isTokenValid = true,
loginType = LoginType.QR,
- passphrase = pendingPassphrase,
+ passphrase = pendingKey.formattedAsString(),
sessionPaths = emptySessionPaths,
)
val matrixClient = rustMatrixClientFactory.create(client)
@@ -422,7 +420,7 @@ class RustMatrixAuthenticationService(
return rustMatrixClientFactory
.getBaseClientBuilder(
sessionPaths = sessionPaths,
- passphrase = pendingPassphrase,
+ clientSecret = pendingKey,
slidingSyncType = ClientBuilderSlidingSync.Discovered,
)
.config()
@@ -454,7 +452,7 @@ class RustMatrixAuthenticationService(
return rustMatrixClientFactory
.getBaseClientBuilder(
sessionPaths = sessionPaths,
- passphrase = pendingPassphrase,
+ clientSecret = pendingKey,
slidingSyncType = ClientBuilderSlidingSync.Discovered,
)
.serverNameOrHomeserverUrl(baseUrlOrServerName)
diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/keys/DefaultPassphraseGenerator.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/keys/DefaultPassphraseGenerator.kt
deleted file mode 100644
index d13ff7779e..0000000000
--- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/keys/DefaultPassphraseGenerator.kt
+++ /dev/null
@@ -1,25 +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.keys
-
-import android.util.Base64
-import dev.zacsweers.metro.AppScope
-import dev.zacsweers.metro.ContributesBinding
-import java.security.SecureRandom
-
-private const val SECRET_SIZE = 256
-
-@ContributesBinding(AppScope::class)
-class DefaultPassphraseGenerator : PassphraseGenerator {
- override fun generatePassphrase(): String? {
- val key = ByteArray(size = SECRET_SIZE)
- SecureRandom().nextBytes(key)
- return Base64.encodeToString(key, Base64.NO_PADDING or Base64.NO_WRAP)
- }
-}
diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/keys/DefaultSecretGenerator.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/keys/DefaultSecretGenerator.kt
new file mode 100644
index 0000000000..bad4ecb169
--- /dev/null
+++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/keys/DefaultSecretGenerator.kt
@@ -0,0 +1,30 @@
+/*
+ * 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.keys
+
+import android.util.Base64
+import dev.zacsweers.metro.AppScope
+import dev.zacsweers.metro.ContributesBinding
+import io.element.android.libraries.androidutils.crypto.ClientSecret
+import java.security.SecureRandom
+
+@ContributesBinding(AppScope::class)
+class DefaultSecretGenerator : SecretGenerator {
+ override fun generatePassphrase(size: Int): ClientSecret.Passphrase? {
+ val key = ByteArray(size = size)
+ SecureRandom().nextBytes(key)
+ return ClientSecret.Passphrase(Base64.encodeToString(key, Base64.NO_PADDING or Base64.NO_WRAP))
+ }
+
+ override fun generateKey(size: Int): ClientSecret.RawKey {
+ val key = ByteArray(size = size)
+ SecureRandom().nextBytes(key)
+ return ClientSecret.RawKey(key)
+ }
+}
diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/keys/PassphraseGenerator.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/keys/PassphraseGenerator.kt
deleted file mode 100644
index e0f925fa28..0000000000
--- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/keys/PassphraseGenerator.kt
+++ /dev/null
@@ -1,17 +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.keys
-
-interface PassphraseGenerator {
- /**
- * Generate a passphrase to encrypt the databases of a session.
- * Return null to not encrypt the databases.
- */
- fun generatePassphrase(): String?
-}
diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/keys/SecretGenerator.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/keys/SecretGenerator.kt
new file mode 100644
index 0000000000..6dfade9b7c
--- /dev/null
+++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/keys/SecretGenerator.kt
@@ -0,0 +1,30 @@
+/*
+ * 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.keys
+
+import io.element.android.libraries.androidutils.crypto.ClientSecret
+
+private const val PASSPHRASE_SIZE = 256
+private const val KEY_SIZE = 32
+
+interface SecretGenerator {
+ /**
+ * Generate a passphrase to encrypt the databases.
+ * @param size the size of the passphrase in bytes, before encoding. The default value is 256 bytes.
+ * @return either a random passphrase or `null` to not encrypt the databases.
+ */
+ fun generatePassphrase(size: Int = PASSPHRASE_SIZE): ClientSecret.Passphrase?
+
+ /**
+ * Generate a key to encrypt the databases.
+ * @param size the size of the key in bytes. The default value is 32 bytes.
+ * @return a random key.
+ */
+ fun generateKey(size: Int = KEY_SIZE): ClientSecret.RawKey
+}
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 6d212d4784..cb387a9d21 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,15 @@ class RustLinkMobileHandler(
)
// We emit Done in case the progress listener was deallocated before generate() sent the Done
_linkMobileStep.emit(LinkMobileStep.Done)
+ } catch (e: HumanQrGrantLoginException.NotFound) {
+ Timber.tag(tag.value).w(e, "Error during QR login grant")
+ // Catch timeout here?
+ if (_linkMobileStep.value is LinkMobileStep.QrReady) {
+ Timber.tag(tag.value).d("Emit QrRotating due to HumanQrGrantLoginException.NotFound")
+ _linkMobileStep.emit(LinkMobileStep.QrRotating)
+ } else {
+ _linkMobileStep.emit(LinkMobileStep.Error(e.map()))
+ }
} catch (e: HumanQrGrantLoginException) {
Timber.tag(tag.value).w(e, "Error during QR login grant")
_linkMobileStep.emit(LinkMobileStep.Error(e.map()))
diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/pushers/RustPushersService.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/pushers/RustPushersService.kt
index 860993d4bc..2e2a36c449 100644
--- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/pushers/RustPushersService.kt
+++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/pushers/RustPushersService.kt
@@ -44,7 +44,8 @@ class RustPushersService(
appDisplayName = setHttpPusherData.appDisplayName,
deviceDisplayName = setHttpPusherData.deviceDisplayName,
profileTag = setHttpPusherData.profileTag,
- lang = setHttpPusherData.lang
+ lang = setHttpPusherData.lang,
+ append = setHttpPusherData.append,
)
}
.mapFailure { it.mapClientException() }
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 e75091eadc..1b02c03e30 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
@@ -421,6 +421,8 @@ class JoinedRustRoom(
roomAvatar = roomPowerLevelsValues.roomAvatar,
roomTopic = roomPowerLevelsValues.roomTopic,
spaceChild = roomPowerLevelsValues.spaceChild,
+ beacon = roomPowerLevelsValues.beacon,
+ beaconInfo = roomPowerLevelsValues.beaconInfo,
)
innerRoom.applyPowerLevelChanges(changes)
}
diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RoomInfoMapper.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RoomInfoMapper.kt
index 0e9aadc65b..a972b3c130 100644
--- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RoomInfoMapper.kt
+++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RoomInfoMapper.kt
@@ -77,6 +77,7 @@ class RoomInfoMapper {
privilegedCreatorRole = it.privilegedCreatorsRole,
isLowPriority = it.isLowPriority,
activeCallIntentConsensus = it.activeRoomCallConsensusIntent.map(),
+ fullyReadEventId = it.fullyReadEventId?.let(::EventId)
)
}
}
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 84f124868e..3fdfb7a179 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
@@ -7,14 +7,25 @@
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 org.matrix.rustcomponents.sdk.ClientBuilder
import org.matrix.rustcomponents.sdk.SqliteStoreBuilder as SdkSqliteStoreBuilder
+/**
+ * Abstraction over the SDK's [SdkSqliteStoreBuilder] to allow configuring it with a ClientSecret and to hide the SDK from the rest of the codebase.
+ */
interface SqliteStoreBuilder {
- fun passphrase(passphrase: String?): SqliteStoreBuilder
+ /**
+ * Configure the builder with a [ClientSecret], if provided. If the [clientSecret] is null, the databases will not be encrypted.
+ */
+ fun secret(clientSecret: ClientSecret?): SqliteStoreBuilder
+
+ /**
+ * Configure the provided [clientBuilder] with the configured [SdkSqliteStoreBuilder] and return it.
+ */
fun setupClientBuilder(clientBuilder: ClientBuilder): ClientBuilder
}
@@ -26,8 +37,15 @@ class RustSqliteStoreBuilder(
cachePath = sessionPaths.cacheDirectory.absolutePath,
).journalSizeLimit(25.megaBytes.into(ByteUnit.BYTES).toUInt())
- override fun passphrase(passphrase: String?): SqliteStoreBuilder {
- inner = inner.passphrase(passphrase)
+ override fun secret(clientSecret: ClientSecret?): SqliteStoreBuilder {
+ when (clientSecret) {
+ null -> Unit
+ is ClientSecret.Passphrase -> inner = inner.passphrase(clientSecret.value)
+ is ClientSecret.RawKey -> {
+ // Ensure the key is 32 bytes long, as required by the SDK
+ inner = inner.key(clientSecret.keyOfSize(32))
+ }
+ }
return this
}
@@ -35,3 +53,15 @@ class RustSqliteStoreBuilder(
return clientBuilder.sqliteStore(this.inner)
}
}
+
+private fun ClientSecret.RawKey.keyOfSize(size: Int): ByteArray {
+ return if (bytes.size == 32) {
+ bytes
+ } else if (bytes.size < 32) {
+ // If the key is shorter than 32 bytes, pad it with zeros
+ bytes + ByteArray(32 - bytes.size)
+ } else {
+ // Otherwise, take the first 32 bytes of the key
+ bytes.copyOfRange(0, 32)
+ }
+}
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 5da4be408d..d44676d34a 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
@@ -12,6 +12,7 @@ import io.element.android.libraries.androidutils.hash.hash
import io.element.android.libraries.core.extensions.runCatchingExceptions
import io.element.android.libraries.matrix.api.core.EventId
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.ImageInfo
@@ -20,6 +21,7 @@ import io.element.android.libraries.matrix.api.media.VideoInfo
import io.element.android.libraries.matrix.api.poll.PollKind
import io.element.android.libraries.matrix.api.room.IntentionalMention
import io.element.android.libraries.matrix.api.room.JoinedRoom
+import io.element.android.libraries.matrix.api.room.join.JoinRule
import io.element.android.libraries.matrix.api.room.location.AssetType
import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem
import io.element.android.libraries.matrix.api.timeline.MsgType
@@ -36,6 +38,8 @@ 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
@@ -43,6 +47,7 @@ import io.element.android.libraries.matrix.impl.timeline.postprocessor.TypingNot
import io.element.android.libraries.matrix.impl.timeline.reply.InReplyToMapper
import io.element.android.libraries.matrix.impl.util.MessageEventContent
import io.element.android.services.toolbox.api.systemclock.SystemClock
+import kotlinx.collections.immutable.ImmutableList
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
@@ -81,7 +86,7 @@ private const val PAGINATION_SIZE = 50
class RustTimeline(
private val inner: InnerTimeline,
override val mode: Timeline.Mode,
- private val systemClock: SystemClock,
+ systemClock: SystemClock,
private val joinedRoom: JoinedRoom,
private val coroutineScope: CoroutineScope,
private val dispatcher: CoroutineDispatcher,
@@ -120,6 +125,15 @@ 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,
+ val isDm: Boolean,
+ val joinRule: JoinRule?,
+ val isEncrypted: Boolean?,
+ )
override val backwardPaginationStatus = MutableStateFlow(
Timeline.PaginationStatus(isPaginating = false, hasMoreToLoad = mode != Timeline.Mode.PinnedEvents)
@@ -220,14 +234,15 @@ class RustTimeline(
_timelineItems,
backwardPaginationStatus,
forwardPaginationStatus,
- joinedRoom.roomInfoFlow.map { it.creators to it.isDm }.distinctUntilChanged(),
+ joinedRoom.roomInfoFlow.map { RoomTimelineInfo(it.creators, it.isDm, it.joinRule, it.isEncrypted) }.distinctUntilChanged(),
) {
timelineItems,
backwardPaginationStatus,
forwardPaginationStatus,
- (roomCreators, isDm),
+ roomInfo,
->
withContext(dispatcher) {
+ val (roomCreators, isDm, joinRule, isEncrypted) = roomInfo
timelineItems
.let { items ->
roomBeginningPostProcessor.process(
@@ -237,6 +252,18 @@ 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,
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
new file mode 100644
index 0000000000..b4d21b36be
--- /dev/null
+++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/postprocessor/FilterEmptyDayPostProcessor.kt
@@ -0,0 +1,40 @@
+/*
+ * 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 ascending timestamp, so events that happened during a day appear after the day separator for that day.
+ // We can use this to determine if a day separator should be kept or not by traversing the list in reverse.
+ var hasEvent = false
+ for (item in items.asReversed()) {
+ 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)
+ }
+ }
+ }
+ // Then reverse the result to restore the original order, minus the empty day separators.
+ .asReversed()
+}
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
new file mode 100644
index 0000000000..806c9369bf
--- /dev/null
+++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/postprocessor/FilterPublicMembershipChangesPostProcessor.kt
@@ -0,0 +1,43 @@
+/*
+ * 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/timeline/postprocessor/RoomBeginningPostProcessor.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/postprocessor/RoomBeginningPostProcessor.kt
index 397280231d..8991d26f9c 100644
--- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/postprocessor/RoomBeginningPostProcessor.kt
+++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/postprocessor/RoomBeginningPostProcessor.kt
@@ -18,7 +18,7 @@ import io.element.android.libraries.matrix.api.timeline.item.event.StateContent
/**
* This timeline post-processor removes the room creation event and the self-join event from the timeline for DMs
- * or add the RoomBeginning item.
+ * or add the RoomBeginning item. For rooms that aren't invite-only and aren't encrypted, it also removes join/leave and profile change events.
*/
class RoomBeginningPostProcessor(private val mode: Timeline.Mode) {
fun process(
diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/util/Token.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/util/Token.kt
index 815e134cf2..f5df21008b 100644
--- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/util/Token.kt
+++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/util/Token.kt
@@ -16,7 +16,8 @@ private val sha256 by lazy { MessageDigest.getInstance("SHA-256") }
@OptIn(ExperimentalStdlibApi::class)
private fun anonymizeToken(token: String): String {
- return sha256.digest(token.toByteArray()).toHexString()
+ // Only keep the first 32 chars (16 bytes) of the hashed token to avoid displaying too much information.
+ return sha256.digest(token.toByteArray()).toHexString().take(32)
}
fun SessionData?.anonymizedTokens(): Pair {
diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/RustClientSessionDelegateTest.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/RustClientSessionDelegateTest.kt
index 6aa3ef0e5b..0036f2f962 100644
--- a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/RustClientSessionDelegateTest.kt
+++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/RustClientSessionDelegateTest.kt
@@ -16,15 +16,11 @@ import io.element.android.libraries.sessionstorage.test.InMemorySessionStore
import io.element.android.libraries.sessionstorage.test.aSessionData
import io.element.android.services.analytics.api.AnalyticsService
import io.element.android.services.analytics.test.FakeAnalyticsService
-import io.element.android.tests.testutils.testCoroutineDispatchers
-import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.TestScope
-import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.test.runTest
import org.junit.Test
import uniffi.matrix_sdk_common.BackgroundTaskFailureReason
-@OptIn(ExperimentalCoroutinesApi::class)
class RustClientSessionDelegateTest {
@Test
fun `saveSessionInKeychain should update the store`() = runTest {
@@ -43,7 +39,6 @@ class RustClientSessionDelegateTest {
refreshToken = "rt",
)
)
- runCurrent()
val result = sessionStore.getLatestSession()
assertThat(result!!.accessToken).isEqualTo("at")
assertThat(result.refreshToken).isEqualTo("rt")
@@ -80,5 +75,4 @@ fun TestScope.aRustClientSessionDelegate(
sessionStore = sessionStore,
appCoroutineScope = this,
analyticsService = analyticsService,
- coroutineDispatchers = testCoroutineDispatchers(),
)
diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/auth/FakePassphraseGenerator.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/auth/FakePassphraseGenerator.kt
deleted file mode 100644
index a089920422..0000000000
--- a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/auth/FakePassphraseGenerator.kt
+++ /dev/null
@@ -1,18 +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.auth
-
-import io.element.android.libraries.matrix.impl.keys.PassphraseGenerator
-import io.element.android.libraries.matrix.test.A_PASSPHRASE
-
-class FakePassphraseGenerator(
- private val passphrase: () -> String? = { A_PASSPHRASE }
-) : PassphraseGenerator {
- override fun generatePassphrase(): String? = passphrase()
-}
diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/auth/FakeSecretGenerator.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/auth/FakeSecretGenerator.kt
new file mode 100644
index 0000000000..12c227946a
--- /dev/null
+++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/auth/FakeSecretGenerator.kt
@@ -0,0 +1,21 @@
+/*
+ * 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.auth
+
+import io.element.android.libraries.androidutils.crypto.ClientSecret
+import io.element.android.libraries.matrix.impl.keys.SecretGenerator
+import io.element.android.libraries.matrix.test.A_PASSPHRASE
+
+class FakeSecretGenerator(
+ private val passphrase: (Int) -> String? = { A_PASSPHRASE },
+ private val key: (Int) -> ByteArray = { ByteArray(it) { 0 } },
+) : SecretGenerator {
+ override fun generatePassphrase(size: Int): ClientSecret.Passphrase? = passphrase(size)?.let { ClientSecret.Passphrase(it) }
+ override fun generateKey(size: Int): ClientSecret.RawKey = ClientSecret.RawKey(key(size))
+}
diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/auth/RustMatrixAuthenticationServiceTest.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/auth/RustMatrixAuthenticationServiceTest.kt
index 7f422acfcf..2b9080195f 100644
--- a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/auth/RustMatrixAuthenticationServiceTest.kt
+++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/auth/RustMatrixAuthenticationServiceTest.kt
@@ -66,7 +66,7 @@ class RustMatrixAuthenticationServiceTest {
coroutineDispatchers = testCoroutineDispatchers(),
sessionStore = sessionStore,
rustMatrixClientFactory = rustMatrixClientFactory,
- passphraseGenerator = FakePassphraseGenerator(),
+ secretGenerator = FakeSecretGenerator(),
oAuthConfigurationProvider = OAuthConfigurationProvider(
buildMeta = aBuildMeta(),
oAuthRedirectUrlProvider = FakeOAuthRedirectUrlProvider(),
diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/factories/RoomInfo.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/factories/RoomInfo.kt
index 2ce64154f7..cb5e221dc9 100644
--- a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/factories/RoomInfo.kt
+++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/factories/RoomInfo.kt
@@ -64,6 +64,7 @@ internal fun aRustRoomInfo(
activeRoomCallConsensusIntent: RtcCallIntentConsensus = RtcCallIntentConsensus.None,
activeServiceMembersCount: Int = 0,
isDm: Boolean = false,
+ fullyReadEventId: String? = null,
) = RoomInfo(
id = id,
displayName = displayName,
@@ -105,4 +106,5 @@ internal fun aRustRoomInfo(
activeRoomCallConsensusIntent = activeRoomCallConsensusIntent,
activeServiceMembersCount = activeServiceMembersCount.toULong(),
isDm = isDm,
+ fullyReadEventId = fullyReadEventId,
)
diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/fakes/FakeFfiClient.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/fakes/FakeFfiClient.kt
index 57ffcddb37..43c36f7bfb 100644
--- a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/fakes/FakeFfiClient.kt
+++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/fakes/FakeFfiClient.kt
@@ -73,6 +73,7 @@ class FakeFfiClient(
deviceDisplayName: String,
profileTag: String?,
lang: String,
+ append: Boolean,
) = Unit
override suspend fun deletePusher(identifiers: PusherIdentifiers) = Unit
diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/keys/DefaultPassphraseGeneratorTest.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/keys/DefaultPassphraseGeneratorTest.kt
deleted file mode 100644
index 465d920074..0000000000
--- a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/keys/DefaultPassphraseGeneratorTest.kt
+++ /dev/null
@@ -1,24 +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.keys
-
-import com.google.common.truth.Truth.assertThat
-import org.junit.Test
-import org.junit.runner.RunWith
-import org.robolectric.RobolectricTestRunner
-
-@RunWith(RobolectricTestRunner::class)
-class DefaultPassphraseGeneratorTest {
- @Test
- fun `check that generated passphrase has the expected length`() {
- val passphraseGenerator = DefaultPassphraseGenerator()
- val passphrase = passphraseGenerator.generatePassphrase()
- assertThat(passphrase!!.length).isEqualTo(342)
- }
-}
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
new file mode 100644
index 0000000000..4bda511d42
--- /dev/null
+++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/keys/DefaultSecretGeneratorTest.kt
@@ -0,0 +1,35 @@
+/*
+ * 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.keys
+
+import com.google.common.truth.Truth.assertThat
+import io.element.android.libraries.androidutils.crypto.ClientSecret
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.RobolectricTestRunner
+
+@RunWith(RobolectricTestRunner::class)
+class DefaultSecretGeneratorTest {
+ @Test
+ fun `check that generated passphrase has the expected length`() {
+ val secretGenerator = DefaultSecretGenerator()
+ val passphrase = secretGenerator.generatePassphrase(256)
+ assertThat(passphrase).isInstanceOf(ClientSecret.Passphrase::class.java)
+ // Size after Base64 encoding should be 4/3 of the original size, without padding
+ assertThat(passphrase!!.value).hasLength(342)
+ }
+
+ @Test
+ fun `check that generated key has the expected length`() {
+ val secretGenerator = DefaultSecretGenerator()
+ val key = secretGenerator.generateKey(123)
+ assertThat(key).isInstanceOf(ClientSecret.RawKey::class.java)
+ assertThat(key.bytes).hasLength(123)
+ }
+}
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 9f71cb7169..ad751cc86b 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
@@ -118,6 +118,35 @@ class RustLinkMobileHandlerTest {
}
}
+ @Test
+ fun `when start throws HumanQrGrantLoginException_NotFound when in state QrReady, the handler emits QrRotating step`() = runTest {
+ val completable = CompletableDeferred()
+ val handler = FakeFfiGrantLoginWithQrCodeHandler(
+ generateResult = {
+ completable.await()
+ throw HumanQrGrantLoginException.NotFound("Timeout")
+ }
+ )
+ val sut = createRustLinkMobileHandler(
+ handler,
+ )
+ sut.linkMobileStep.test {
+ val initialItem = awaitItem()
+ assertThat(initialItem).isEqualTo(LinkMobileStep.Uninitialized)
+ backgroundScope.launch {
+ sut.start()
+ }
+ runCurrent()
+ handler.emitGenerateProgress(GrantGeneratedQrLoginProgress.QrReady(FakeFfiQrCodeData(toBytesResult = { QR_CODE_DATA_RECIPROCATE })))
+ val readyState = awaitItem()
+ assertThat(readyState).isInstanceOf(LinkMobileStep.QrReady::class.java)
+ // 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/pushers/RustPushersServiceTest.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/pushers/RustPushersServiceTest.kt
index 61b2965cdd..b58c2985ff 100644
--- a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/pushers/RustPushersServiceTest.kt
+++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/pushers/RustPushersServiceTest.kt
@@ -48,6 +48,7 @@ private fun aSetHttpPusherData(
deviceDisplayName: String = "deviceDisplayName",
profileTag: String = "profileTag",
lang: String = "lang",
+ append: Boolean = false,
) = SetHttpPusherData(
pushKey = pushKey,
appId = appId,
@@ -56,7 +57,8 @@ private fun aSetHttpPusherData(
appDisplayName = appDisplayName,
deviceDisplayName = deviceDisplayName,
profileTag = profileTag,
- lang = lang
+ lang = lang,
+ append = append,
)
private fun aUnsetHttpPusherData(
diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/room/RoomInfoMapperTest.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/room/RoomInfoMapperTest.kt
index 56b480d97f..dc3b16ca3c 100644
--- a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/room/RoomInfoMapperTest.kt
+++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/room/RoomInfoMapperTest.kt
@@ -87,6 +87,7 @@ class RoomInfoMapperTest {
isLowPriority = true,
activeRoomCallConsensusIntent = RtcCallIntentConsensus.Full(RtcCallIntent.AUDIO),
isDm = true,
+ fullyReadEventId = AN_EVENT_ID.value,
)
)
).isEqualTo(
@@ -138,6 +139,7 @@ class RoomInfoMapperTest {
isLowPriority = true,
activeCallIntentConsensus = CallIntentConsensus.Full(CallIntent.AUDIO),
isDm = true,
+ fullyReadEventId = AN_EVENT_ID,
)
)
}
@@ -184,6 +186,7 @@ class RoomInfoMapperTest {
isLowPriority = true,
activeRoomCallConsensusIntent = RtcCallIntentConsensus.None,
isDm = false,
+ fullyReadEventId = AN_EVENT_ID.value,
)
)
).isEqualTo(
@@ -229,6 +232,7 @@ class RoomInfoMapperTest {
isLowPriority = true,
activeCallIntentConsensus = CallIntentConsensus.None,
isDm = false,
+ fullyReadEventId = AN_EVENT_ID,
)
)
}
diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/storage/FakeSqliteStoreBuilder.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/storage/FakeSqliteStoreBuilder.kt
index 2f12587f5a..7eeff7db33 100644
--- a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/storage/FakeSqliteStoreBuilder.kt
+++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/storage/FakeSqliteStoreBuilder.kt
@@ -7,10 +7,11 @@
package io.element.android.libraries.matrix.impl.storage
+import io.element.android.libraries.androidutils.crypto.ClientSecret
import org.matrix.rustcomponents.sdk.ClientBuilder
class FakeSqliteStoreBuilder : SqliteStoreBuilder {
- override fun passphrase(passphrase: String?): SqliteStoreBuilder = this
+ override fun secret(clientSecret: ClientSecret?): SqliteStoreBuilder = this
override fun setupClientBuilder(clientBuilder: ClientBuilder): ClientBuilder {
return clientBuilder
diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/timeline/postprocessor/FilterEmptyDayPostProcessorTest.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/timeline/postprocessor/FilterEmptyDayPostProcessorTest.kt
new file mode 100644
index 0000000000..67d7e3621d
--- /dev/null
+++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/timeline/postprocessor/FilterEmptyDayPostProcessorTest.kt
@@ -0,0 +1,118 @@
+/*
+ * 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 com.google.common.truth.Truth.assertThat
+import io.element.android.libraries.matrix.api.core.UniqueId
+import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem
+import io.element.android.libraries.matrix.api.timeline.item.virtual.VirtualTimelineItem
+import io.element.android.libraries.matrix.test.timeline.anEventTimelineItem
+import org.junit.Test
+
+private const val TODAY = 1_779_779_967_000
+private const val YESTERDAY = TODAY - 24 * 60 * 60 * 1000
+private const val DAY_BEFORE_YESTERDAY = YESTERDAY - 24 * 60 * 60 * 1000
+
+class FilterEmptyDayPostProcessorTest {
+ private val anEvent = MatrixTimelineItem.Event(
+ uniqueId = UniqueId("event"),
+ event = anEventTimelineItem(),
+ )
+
+ private fun aDaySeparator(timestmap: Long) = MatrixTimelineItem.Virtual(
+ uniqueId = UniqueId("day_$timestmap"),
+ virtual = VirtualTimelineItem.DayDivider(timestmap)
+ )
+
+ @Test
+ fun `filterEmptyDaySeparators keeps day separator with events after it`() {
+ val items = listOf(
+ aDaySeparator(TODAY),
+ anEvent,
+ )
+ val result = FilterEmptyDayPostProcessor().process(items)
+ assertThat(result).hasSize(2)
+ assertThat(result[0]).isEqualTo(aDaySeparator(TODAY))
+ assertThat(result[1]).isEqualTo(anEvent)
+ }
+
+ @Test
+ fun `filterEmptyDaySeparators removes day separator with no events after it`() {
+ val items = listOf(
+ aDaySeparator(YESTERDAY),
+ aDaySeparator(TODAY),
+ )
+ val result = FilterEmptyDayPostProcessor().process(items)
+ assertThat(result).isEmpty()
+ }
+
+ @Test
+ fun `filterEmptyDaySeparators removes second day separator and keeps first when only first has events`() {
+ val items = listOf(
+ aDaySeparator(YESTERDAY),
+ anEvent,
+ aDaySeparator(TODAY),
+ )
+ val result = FilterEmptyDayPostProcessor().process(items)
+ assertThat(result).hasSize(2)
+ assertThat(result[0]).isEqualTo(aDaySeparator(YESTERDAY))
+ assertThat(result[1]).isEqualTo(anEvent)
+ }
+
+ @Test
+ fun `filterEmptyDaySeparators handles multiple day separators in a row with no events`() {
+ val items = listOf(
+ aDaySeparator(TODAY),
+ aDaySeparator(YESTERDAY),
+ aDaySeparator(DAY_BEFORE_YESTERDAY),
+ )
+ val result = FilterEmptyDayPostProcessor().process(items)
+ assertThat(result).isEmpty()
+ }
+
+ @Test
+ fun `filterEmptyDaySeparators keeps all items when no day separators`() {
+ val items = listOf(
+ anEvent.copy(uniqueId = UniqueId("event2")),
+ anEvent,
+ )
+ val result = FilterEmptyDayPostProcessor().process(items)
+ assertThat(result).hasSize(2)
+ }
+
+ @Test
+ fun `filterEmptyDaySeparators removes day separator preceded by non-event virtual item`() {
+ val readMarker = MatrixTimelineItem.Virtual(
+ uniqueId = UniqueId("readMarker"),
+ virtual = VirtualTimelineItem.ReadMarker
+ )
+ val items = listOf(
+ aDaySeparator(TODAY),
+ readMarker,
+ )
+ val result = FilterEmptyDayPostProcessor().process(items)
+ assertThat(result).hasSize(1)
+ assertThat(result[0]).isEqualTo(readMarker)
+ }
+
+ @Test
+ fun `filterEmptyDaySeparators keeps day separator when non-event virtual items are between separator and event`() {
+ val readMarker = MatrixTimelineItem.Virtual(
+ uniqueId = UniqueId("readMarker"),
+ virtual = VirtualTimelineItem.ReadMarker
+ )
+ val items = listOf(
+ aDaySeparator(TODAY),
+ readMarker,
+ anEvent,
+ )
+ val result = FilterEmptyDayPostProcessor().process(items)
+ assertThat(result).hasSize(3)
+ assertThat(result[0]).isEqualTo(aDaySeparator(TODAY))
+ }
+}
diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/timeline/postprocessor/FilterPublicMembershipChangesPostProcessorTest.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/timeline/postprocessor/FilterPublicMembershipChangesPostProcessorTest.kt
new file mode 100644
index 0000000000..dd899baf5b
--- /dev/null
+++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/timeline/postprocessor/FilterPublicMembershipChangesPostProcessorTest.kt
@@ -0,0 +1,75 @@
+/*
+ * 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 com.google.common.truth.Truth.assertThat
+import io.element.android.libraries.matrix.api.room.join.JoinRule
+import org.junit.Test
+
+class FilterPublicMembershipChangesPostProcessorTest {
+ @Test
+ fun `processor removes join, leave, and profile events in unencrypted public rooms`() {
+ val timelineItems = listOf(
+ roomCreateEvent,
+ roomCreatorJoinEvent,
+ otherMemberJoinEvent,
+ messageEvent,
+ otherMemberLeaveEvent,
+ profileChangeEvent,
+ )
+ val expected = listOf(
+ roomCreateEvent,
+ messageEvent,
+ )
+ val processor = FilterPublicMembershipChangesPostProcessor()
+ val processedItems = processor.process(
+ timelineItems,
+ joinRule = JoinRule.Public,
+ isEncrypted = false,
+ )
+ assertThat(processedItems).isEqualTo(expected)
+ }
+
+ @Test
+ fun `processor keeps all events in encrypted public rooms`() {
+ val timelineItems = listOf(
+ roomCreateEvent,
+ roomCreatorJoinEvent,
+ otherMemberJoinEvent,
+ messageEvent,
+ otherMemberLeaveEvent,
+ profileChangeEvent,
+ )
+ val processor = FilterPublicMembershipChangesPostProcessor()
+ val processedItems = processor.process(
+ timelineItems,
+ joinRule = JoinRule.Public,
+ isEncrypted = true,
+ )
+ assertThat(processedItems).isEqualTo(timelineItems)
+ }
+
+ @Test
+ fun `processor keeps membership events in invite-only rooms`() {
+ val timelineItems = listOf(
+ roomCreateEvent,
+ roomCreatorJoinEvent,
+ otherMemberJoinEvent,
+ messageEvent,
+ otherMemberLeaveEvent,
+ profileChangeEvent,
+ )
+ val processor = FilterPublicMembershipChangesPostProcessor()
+ val processedItems = processor.process(
+ timelineItems,
+ joinRule = JoinRule.Invite,
+ isEncrypted = null,
+ )
+ assertThat(processedItems).isEqualTo(timelineItems)
+ }
+}
diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/timeline/postprocessor/Fixtures.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/timeline/postprocessor/Fixtures.kt
index 50f8637096..cda5a07b8e 100644
--- a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/timeline/postprocessor/Fixtures.kt
+++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/timeline/postprocessor/Fixtures.kt
@@ -17,6 +17,7 @@ import io.element.android.libraries.matrix.api.timeline.item.virtual.VirtualTime
import io.element.android.libraries.matrix.test.A_USER_ID
import io.element.android.libraries.matrix.test.A_USER_ID_2
import io.element.android.libraries.matrix.test.timeline.aMessageContent
+import io.element.android.libraries.matrix.test.timeline.aProfileChangeMessageContent
import io.element.android.libraries.matrix.test.timeline.anEventTimelineItem
import io.element.android.libraries.matrix.test.timeline.item.event.aRoomMembershipContent
@@ -36,6 +37,14 @@ internal val otherMemberJoinEvent = MatrixTimelineItem.Event(
uniqueId = UniqueId("m.room.member_other"),
event = anEventTimelineItem(content = aRoomMembershipContent(userId = A_USER_ID_2, change = MembershipChange.JOINED))
)
+internal val otherMemberLeaveEvent = MatrixTimelineItem.Event(
+ uniqueId = UniqueId("m.room.member_leave"),
+ event = anEventTimelineItem(content = aRoomMembershipContent(userId = A_USER_ID_2, change = MembershipChange.LEFT))
+)
+internal val profileChangeEvent = MatrixTimelineItem.Event(
+ uniqueId = UniqueId("m.room.member_profile"),
+ event = anEventTimelineItem(content = aProfileChangeMessageContent(displayName = "New Name", prevDisplayName = "Old Name"))
+)
internal val messageEvent = MatrixTimelineItem.Event(
uniqueId = UniqueId("m.room.message"),
event = anEventTimelineItem(content = aMessageContent("hi"))
diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/timeline/postprocessor/RoomBeginningPostProcessorTest.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/timeline/postprocessor/RoomBeginningPostProcessorTest.kt
index dbeba39973..680422827c 100644
--- a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/timeline/postprocessor/RoomBeginningPostProcessorTest.kt
+++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/timeline/postprocessor/RoomBeginningPostProcessorTest.kt
@@ -96,7 +96,12 @@ class RoomBeginningPostProcessorTest {
messageEvent,
)
val processor = RoomBeginningPostProcessor(Timeline.Mode.Live)
- val processedItems = processor.process(timelineItems, isDm = true, roomCreator = A_USER_ID, hasMoreToLoadBackwards = false)
+ val processedItems = processor.process(
+ timelineItems,
+ isDm = true,
+ roomCreator = A_USER_ID,
+ hasMoreToLoadBackwards = false
+ )
assertThat(processedItems).isEqualTo(expected)
}
@@ -107,7 +112,12 @@ class RoomBeginningPostProcessorTest {
roomCreatorJoinEvent,
)
val processor = RoomBeginningPostProcessor(Timeline.Mode.Live)
- val processedItems = processor.process(timelineItems, isDm = true, roomCreator = A_USER_ID, hasMoreToLoadBackwards = true)
+ val processedItems = processor.process(
+ timelineItems,
+ isDm = true,
+ roomCreator = A_USER_ID,
+ hasMoreToLoadBackwards = true
+ )
assertThat(processedItems).isEmpty()
}
@@ -117,7 +127,12 @@ class RoomBeginningPostProcessorTest {
roomCreatorJoinEvent,
)
val processor = RoomBeginningPostProcessor(Timeline.Mode.Live)
- val processedItems = processor.process(timelineItems, isDm = true, roomCreator = A_USER_ID, hasMoreToLoadBackwards = true)
+ val processedItems = processor.process(
+ timelineItems,
+ isDm = true,
+ roomCreator = A_USER_ID,
+ hasMoreToLoadBackwards = true
+ )
assertThat(processedItems).isEmpty()
}
@@ -128,7 +143,12 @@ class RoomBeginningPostProcessorTest {
otherMemberJoinEvent,
)
val processor = RoomBeginningPostProcessor(Timeline.Mode.Live)
- val processedItems = processor.process(timelineItems, isDm = true, roomCreator = A_USER_ID, hasMoreToLoadBackwards = true)
+ val processedItems = processor.process(
+ timelineItems,
+ isDm = true,
+ roomCreator = A_USER_ID,
+ hasMoreToLoadBackwards = true
+ )
assertThat(processedItems).isEqualTo(listOf(otherMemberJoinEvent))
}
}
diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/FakeMatrixClient.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/FakeMatrixClient.kt
index af28dc37e4..3278eae855 100644
--- a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/FakeMatrixClient.kt
+++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/FakeMatrixClient.kt
@@ -115,6 +115,7 @@ class FakeMatrixClient(
private val addRecentEmojiLambda: (String) -> Result = { Result.success(Unit) },
private val markRoomAsFullyReadResult: (RoomId, EventId) -> Result = { _, _ -> lambdaError() },
private val performDatabaseVacuumLambda: () -> Result = { lambdaError() },
+ private val getMapStyleUrlResult: () -> Result = { lambdaError() },
private val getDatabaseSizesLambda: () -> Result = { lambdaError() },
private val resetWellKnownConfigLambda: () -> Result = { lambdaError() },
) : MatrixClient {
@@ -373,6 +374,10 @@ class FakeMatrixClient(
return performDatabaseVacuumLambda()
}
+ override suspend fun getMapStyleUrl(): Result = simulateLongTask {
+ getMapStyleUrlResult()
+ }
+
override suspend fun canLinkNewDevice(): Result = simulateLongTask {
return canLinkNewDeviceResult()
}
diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/RoomInfoFixture.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/RoomInfoFixture.kt
index c15330e9dc..3293570eec 100644
--- a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/RoomInfoFixture.kt
+++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/RoomInfoFixture.kt
@@ -72,6 +72,7 @@ fun aRoomInfo(
isLowPriority: Boolean = false,
activeCallIntentConsensus: CallIntentConsensus = CallIntentConsensus.None,
isDm: Boolean = false,
+ fullyReadEventId: EventId? = null,
) = RoomInfo(
id = id,
name = name,
@@ -111,4 +112,5 @@ fun aRoomInfo(
isLowPriority = isLowPriority,
activeCallIntentConsensus = activeCallIntentConsensus,
isDm = isDm,
+ fullyReadEventId = fullyReadEventId,
)
diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/RoomSummaryFixture.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/RoomSummaryFixture.kt
index 32635a7eea..392aa880c9 100644
--- a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/RoomSummaryFixture.kt
+++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/RoomSummaryFixture.kt
@@ -81,6 +81,7 @@ fun aRoomSummary(
privilegedCreatorRole: Boolean = false,
isLowPriority: Boolean = false,
activeCallIntentConsensus: CallIntentConsensus = CallIntentConsensus.None,
+ fullyReadEventId: EventId? = null,
) = RoomSummary(
info = RoomInfo(
id = roomId,
@@ -121,6 +122,7 @@ fun aRoomSummary(
isLowPriority = isLowPriority,
activeCallIntentConsensus = activeCallIntentConsensus,
isDm = false,
+ fullyReadEventId = fullyReadEventId,
),
latestEvent = latestEvent,
)
diff --git a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/AvatarActionBottomSheet.kt b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/AvatarActionBottomSheet.kt
index 880a7a9df6..32899a8d2c 100644
--- a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/AvatarActionBottomSheet.kt
+++ b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/AvatarActionBottomSheet.kt
@@ -104,7 +104,7 @@ private fun AvatarActionBottomSheetContent(
leadingContent = ListItemContent.Icon(IconSource.Resource(action.iconResourceId)),
style = when {
action.destructive -> ListItemStyle.Destructive
- else -> ListItemStyle.Primary
+ else -> ListItemStyle.Default
}
)
}
diff --git a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/AvatarPickerView.kt b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/AvatarPickerView.kt
index e743a2447c..81822a04cc 100644
--- a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/AvatarPickerView.kt
+++ b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/AvatarPickerView.kt
@@ -42,6 +42,7 @@ import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.clearAndSetSemantics
import androidx.compose.ui.semantics.contentDescription
+import androidx.compose.ui.semantics.hideFromAccessibility
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
@@ -183,6 +184,7 @@ private fun PickButton(
}
}
+@Suppress("ModifierClickableOrder")
@Composable
private fun BoxScope.OverlayEditButton(
onClick: () -> Unit,
@@ -196,7 +198,10 @@ private fun BoxScope.OverlayEditButton(
.clip(CircleShape)
.clickable(interactionSource = interactionSource, onClick = onClick, indication = null)
.background(ElementTheme.colors.bgCanvasDefault)
- .border(BorderStroke(1.dp, ElementTheme.colors.borderInteractiveSecondary), shape = CircleShape),
+ .border(BorderStroke(1.dp, ElementTheme.colors.borderInteractiveSecondary), shape = CircleShape)
+ .clearAndSetSemantics {
+ hideFromAccessibility()
+ },
contentAlignment = Alignment.Center,
) {
Icon(
diff --git a/libraries/matrixui/src/main/res/values-ca/translations.xml b/libraries/matrixui/src/main/res/values-ca/translations.xml
new file mode 100644
index 0000000000..44d3e10c55
--- /dev/null
+++ b/libraries/matrixui/src/main/res/values-ca/translations.xml
@@ -0,0 +1,7 @@
+
+
+ "Envia invitació"
+ "Vols iniciar un xat amb %1$s?"
+ "Enviar invitació?"
+ "%1$s (%2$s) t\'ha convidat"
+
diff --git a/libraries/mediaupload/test/src/main/kotlin/io/element/android/libraries/mediaupload/test/FakeMediaPreProcessor.kt b/libraries/mediaupload/test/src/main/kotlin/io/element/android/libraries/mediaupload/test/FakeMediaPreProcessor.kt
index c07ebb6ec9..3750060eef 100644
--- a/libraries/mediaupload/test/src/main/kotlin/io/element/android/libraries/mediaupload/test/FakeMediaPreProcessor.kt
+++ b/libraries/mediaupload/test/src/main/kotlin/io/element/android/libraries/mediaupload/test/FakeMediaPreProcessor.kt
@@ -31,6 +31,10 @@ class FakeMediaPreProcessor(
var cleanUpCallCount = 0
private set
+ /** The [MediaOptimizationConfig] passed to the most recent [process] call, or `null` if it was never called. */
+ var lastMediaOptimizationConfig: MediaOptimizationConfig? = null
+ private set
+
private var result: Result = Result.success(
MediaUploadInfo.AnyFile(
File("test"),
@@ -51,6 +55,7 @@ class FakeMediaPreProcessor(
): Result = simulateLongTask {
processLatch?.await()
processCallCount++
+ lastMediaOptimizationConfig = mediaOptimizationConfig
result
}
diff --git a/libraries/mediaviewer/api/src/main/kotlin/io/element/android/libraries/mediaviewer/api/MediaInfo.kt b/libraries/mediaviewer/api/src/main/kotlin/io/element/android/libraries/mediaviewer/api/MediaInfo.kt
index fedf376a60..adfffd8af4 100644
--- a/libraries/mediaviewer/api/src/main/kotlin/io/element/android/libraries/mediaviewer/api/MediaInfo.kt
+++ b/libraries/mediaviewer/api/src/main/kotlin/io/element/android/libraries/mediaviewer/api/MediaInfo.kt
@@ -103,6 +103,30 @@ fun aPdfMediaInfo(
duration = null,
)
+fun aZipMediaInfo(
+ filename: String = "a zip file.zip",
+ caption: String? = null,
+ formattedCaption: CharSequence? = null,
+ senderName: String? = null,
+ dateSent: String? = null,
+ dateSentFull: String? = null,
+): MediaInfo = MediaInfo(
+ filename = filename,
+ fileSize = 12 * 1024 * 1024,
+ caption = caption,
+ formattedCaption = formattedCaption,
+ mimeType = MimeTypes.Pdf,
+ formattedFileSize = "45MB",
+ fileExtension = "zip",
+ senderId = UserId("@alice:server.org"),
+ senderName = senderName,
+ senderAvatar = null,
+ dateSent = dateSent,
+ dateSentFull = dateSentFull,
+ waveform = null,
+ duration = null,
+)
+
fun anApkMediaInfo(
senderId: UserId? = UserId("@alice:server.org"),
senderName: String? = null,
diff --git a/libraries/mediaviewer/api/src/main/kotlin/io/element/android/libraries/mediaviewer/api/helper/FileExtensionAndSizeFormatter.kt b/libraries/mediaviewer/api/src/main/kotlin/io/element/android/libraries/mediaviewer/api/helper/FileExtensionAndSizeFormatter.kt
index fc40bca8dd..e546f50565 100644
--- a/libraries/mediaviewer/api/src/main/kotlin/io/element/android/libraries/mediaviewer/api/helper/FileExtensionAndSizeFormatter.kt
+++ b/libraries/mediaviewer/api/src/main/kotlin/io/element/android/libraries/mediaviewer/api/helper/FileExtensionAndSizeFormatter.kt
@@ -12,8 +12,8 @@ fun formatFileExtensionAndSize(extension: String, size: String?): String {
return buildString {
append(extension.uppercase())
if (size != null) {
- append(' ')
- append("($size)")
+ append(" • ")
+ append(size)
}
}
}
diff --git a/libraries/mediaviewer/impl/build.gradle.kts b/libraries/mediaviewer/impl/build.gradle.kts
index f2dbf1aacf..b723578829 100644
--- a/libraries/mediaviewer/impl/build.gradle.kts
+++ b/libraries/mediaviewer/impl/build.gradle.kts
@@ -50,6 +50,7 @@ dependencies {
implementation(projects.libraries.matrix.api)
implementation(projects.libraries.matrixmedia.api)
implementation(projects.libraries.uiStrings)
+ implementation(projects.libraries.uiUtils)
implementation(projects.libraries.voiceplayer.api)
implementation(projects.services.toolbox.api)
diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/datasource/MediaGalleryDataSource.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/datasource/MediaGalleryDataSource.kt
index f5418c76c9..7bdd4872a3 100644
--- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/datasource/MediaGalleryDataSource.kt
+++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/datasource/MediaGalleryDataSource.kt
@@ -32,6 +32,7 @@ import timber.log.Timber
import java.util.concurrent.atomic.AtomicBoolean
interface MediaGalleryDataSource {
+ val isReady: Boolean
fun start(coroutineScope: CoroutineScope)
fun groupedMediaItemsFlow(): Flow>
fun getLastData(): AsyncData
@@ -49,7 +50,7 @@ class TimelineMediaGalleryDataSource(
) : MediaGalleryDataSource {
private var timeline: Timeline? = null
- private val groupedMediaItemsFlow = MutableSharedFlow>(replay = 1)
+ private val groupedMediaItemsFlow = MutableSharedFlow>(replay = 1, extraBufferCapacity = 10)
override fun groupedMediaItemsFlow(): Flow> = groupedMediaItemsFlow
@@ -59,9 +60,12 @@ class TimelineMediaGalleryDataSource(
private val isStarted = AtomicBoolean(false)
+ override val isReady: Boolean get() = isStarted.get() && timeline != null
+
@OptIn(ExperimentalCoroutinesApi::class)
override fun start(coroutineScope: CoroutineScope) {
if (!isStarted.compareAndSet(false, true)) {
+ Timber.w("MediaGalleryDataSource for room ${room.roomId} is already started, ignoring subsequent start call")
return
}
flow {
@@ -73,10 +77,12 @@ class TimelineMediaGalleryDataSource(
}
mediaTimeline.getTimeline().fold(
{
+ Timber.d("Timeline media data source flow started for room ${room.roomId}")
timeline = it
emit(it)
},
{
+ Timber.e(it, "Failed to get media timeline for room ${room.roomId}")
groupedMediaItemsFlow.emit(AsyncData.Failure(it))
},
)
@@ -107,13 +113,13 @@ class TimelineMediaGalleryDataSource(
}
override suspend fun loadMore(direction: Timeline.PaginationDirection) {
- timeline?.paginate(direction)
+ timeline?.paginate(direction) ?: Timber.w("Timeline is not ready yet, cannot load more media items for room ${room.roomId}")
}
override suspend fun deleteItem(eventId: EventId) {
timeline?.redactEvent(
eventOrTransactionId = eventId.toEventOrTransactionId(),
reason = null,
- )
+ ) ?: Timber.w("Timeline is not ready yet, cannot delete media item with eventId $eventId for room ${room.roomId}")
}
}
diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/details/MediaBottomSheetState.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/details/MediaBottomSheetState.kt
index 722126fac6..245e3a14b8 100644
--- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/details/MediaBottomSheetState.kt
+++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/details/MediaBottomSheetState.kt
@@ -16,6 +16,7 @@ sealed interface MediaBottomSheetState {
data object Hidden : MediaBottomSheetState
data class Details(
+ val fromGallery: Boolean,
val eventId: EventId?,
val canDelete: Boolean,
val mediaInfo: MediaInfo,
diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/details/MediaBottomSheetStateDetailsProvider.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/details/MediaBottomSheetStateDetailsProvider.kt
index 99a6925b9d..7540d49b8a 100644
--- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/details/MediaBottomSheetStateDetailsProvider.kt
+++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/details/MediaBottomSheetStateDetailsProvider.kt
@@ -29,10 +29,14 @@ open class MediaBottomSheetStateDetailsProvider : PreviewParameterProvider
@@ -148,16 +114,45 @@ fun MediaDetailsBottomSheet(
ListItem(
leadingContent = icon,
headlineContent = { Text(wording) },
- style = ListItemStyle.Primary,
onClick = {
onOpenWith(state.eventId)
}
)
+ ListItem(
+ leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.VisibilityOn())),
+ headlineContent = { Text(stringResource(CommonStrings.action_view_in_timeline)) },
+ onClick = {
+ onViewInTimeline(state.eventId)
+ }
+ )
+ if (state.fromGallery) {
+ ListItem(
+ leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.ShareAndroid())),
+ headlineContent = { Text(stringResource(CommonStrings.action_share)) },
+ onClick = {
+ onShare(state.eventId)
+ }
+ )
+ ListItem(
+ leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Download())),
+ headlineContent = { Text(stringResource(CommonStrings.action_download)) },
+ onClick = {
+ onDownload(state.eventId)
+ }
+ )
+ }
+ ListItem(
+ leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Forward())),
+ headlineContent = { Text(stringResource(CommonStrings.action_forward)) },
+ onClick = {
+ onForward(state.eventId)
+ }
+ )
if (state.canDelete) {
HorizontalDivider()
ListItem(
leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Delete())),
- headlineContent = { Text(stringResource(CommonStrings.action_delete)) },
+ headlineContent = { Text(stringResource(CommonStrings.action_delete_file)) },
style = ListItemStyle.Destructive,
onClick = {
onDelete(state.eventId)
@@ -221,16 +216,14 @@ private fun SenderRow(
}
@Composable
-private fun ColumnScope.Title() {
+private fun Title() {
Text(
modifier = Modifier
- .align(Alignment.CenterHorizontally)
.padding(top = 16.dp, bottom = 8.dp, start = 16.dp, end = 16.dp)
.semantics {
heading()
},
text = stringResource(R.string.screen_media_details_title),
- textAlign = TextAlign.Center,
style = ElementTheme.typography.fontBodyLgMedium,
color = ElementTheme.colors.textPrimary,
)
diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaGalleryPresenter.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaGalleryPresenter.kt
index e7caa12f2e..25e27330b4 100644
--- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaGalleryPresenter.kt
+++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaGalleryPresenter.kt
@@ -94,7 +94,9 @@ class MediaGalleryPresenter(
mode = event.mode
}
is MediaGalleryEvent.LoadMore -> coroutineScope.launch {
- mediaGalleryDataSource.loadMore(event.direction)
+ if (mediaGalleryDataSource.isReady) {
+ mediaGalleryDataSource.loadMore(event.direction)
+ }
}
is MediaGalleryEvent.Delete -> coroutineScope.launch {
mediaGalleryDataSource.deleteItem(event.eventId)
@@ -127,6 +129,7 @@ class MediaGalleryPresenter(
}
is MediaGalleryEvent.OpenInfo -> coroutineScope.launch {
mediaBottomSheetState = MediaBottomSheetState.Details(
+ fromGallery = true,
eventId = event.mediaItem.eventId(),
canDelete = when (event.mediaItem.mediaInfo().senderId) {
null -> false
diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/AndroidLocalMediaFactory.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/AndroidLocalMediaFactory.kt
index 83e8a17cf4..09164e36d1 100644
--- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/AndroidLocalMediaFactory.kt
+++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/AndroidLocalMediaFactory.kt
@@ -9,7 +9,9 @@
package io.element.android.libraries.mediaviewer.impl.local
import android.content.Context
+import android.graphics.BitmapFactory
import android.net.Uri
+import android.webkit.MimeTypeMap
import androidx.core.net.toUri
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesBinding
@@ -17,6 +19,7 @@ import io.element.android.libraries.androidutils.file.getFileName
import io.element.android.libraries.androidutils.file.getFileSize
import io.element.android.libraries.androidutils.file.getMimeType
import io.element.android.libraries.androidutils.filesize.FileSizeFormatter
+import io.element.android.libraries.core.data.tryOrNull
import io.element.android.libraries.core.mimetype.MimeTypes
import io.element.android.libraries.di.annotations.ApplicationContext
import io.element.android.libraries.matrix.api.core.UserId
@@ -85,8 +88,12 @@ class AndroidLocalMediaFactory(
waveform: List?,
duration: String?,
): LocalMedia {
- val resolvedMimeType = mimeType ?: context.getMimeType(uri) ?: MimeTypes.OctetStream
val fileName = name ?: context.getFileName(uri) ?: ""
+ val resolvedMimeType = resolveMimeType(
+ uri = uri,
+ mimeType = mimeType,
+ fileName = fileName,
+ )
val fileSize = context.getFileSize(uri)
val calculatedFormattedFileSize = formattedFileSize ?: fileSizeFormatter.format(fileSize)
val fileExtension = fileExtensionExtractor.extractFromName(fileName)
@@ -110,4 +117,36 @@ class AndroidLocalMediaFactory(
)
)
}
+
+ private fun resolveMimeType(
+ uri: Uri,
+ mimeType: String?,
+ fileName: String,
+ ): String {
+ val explicitMimeType = mimeType.takeUnless { it.isNullOrBlank() || it == MimeTypes.OctetStream }
+ if (explicitMimeType != null) return explicitMimeType
+
+ val resolverMimeType = context.getMimeType(uri).takeUnless { it.isNullOrBlank() || it == MimeTypes.OctetStream }
+ if (resolverMimeType != null) return resolverMimeType
+
+ val decodedImageMimeType = decodeImageMimeType(uri)
+ if (decodedImageMimeType != null) return decodedImageMimeType
+
+ val extensionMimeType = MimeTypeMap.getSingleton().getMimeTypeFromExtension(
+ fileExtensionExtractor.extractFromName(fileName)
+ )
+ if (!extensionMimeType.isNullOrBlank()) return extensionMimeType
+
+ return MimeTypes.OctetStream
+ }
+
+ private fun decodeImageMimeType(uri: Uri): String? {
+ return tryOrNull {
+ context.contentResolver.openInputStream(uri)?.use { inputStream ->
+ val options = BitmapFactory.Options().apply { inJustDecodeBounds = true }
+ BitmapFactory.decodeStream(inputStream, null, options)
+ options.outMimeType
+ }
+ }
+ }
}
diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/DefaultLocalMediaRenderer.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/DefaultLocalMediaRenderer.kt
index 96450d99bf..319aebe33e 100644
--- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/DefaultLocalMediaRenderer.kt
+++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/DefaultLocalMediaRenderer.kt
@@ -41,6 +41,7 @@ class DefaultLocalMediaRenderer(
textFileViewer = textFileViewer,
audioFocus = audioFocus,
onClick = {},
+ onOpenWith = null,
)
}
}
diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/LocalMediaView.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/LocalMediaView.kt
index cc276b52c8..c19b1a24c0 100644
--- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/LocalMediaView.kt
+++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/LocalMediaView.kt
@@ -31,6 +31,7 @@ fun LocalMediaView(
bottomPaddingInPixels: Int,
audioFocus: AudioFocus?,
onClick: () -> Unit,
+ onOpenWith: (() -> Unit)?,
textFileViewer: TextFileViewer,
modifier: Modifier = Modifier,
isDisplayed: Boolean = true,
@@ -80,7 +81,7 @@ fun LocalMediaView(
uri = localMedia?.uri,
info = mediaInfo,
modifier = modifier,
- onClick = onClick,
+ onOpenWith = onOpenWith,
)
}
}
diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/file/MediaFileView.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/file/MediaFileView.kt
index 912ea88e99..a1c57ef272 100644
--- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/file/MediaFileView.kt
+++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/file/MediaFileView.kt
@@ -9,23 +9,16 @@
package io.element.android.libraries.mediaviewer.impl.local.file
import android.net.Uri
-import androidx.compose.foundation.background
-import androidx.compose.foundation.clickable
-import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
-import androidx.compose.foundation.layout.size
-import androidx.compose.foundation.shape.CircleShape
import androidx.compose.runtime.Composable
-import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
-import androidx.compose.ui.draw.clip
-import androidx.compose.ui.draw.rotate
+import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.PreviewParameter
@@ -33,55 +26,54 @@ import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.libraries.core.bool.orFalse
+import io.element.android.libraries.core.mimetype.MimeTypes
import io.element.android.libraries.core.mimetype.MimeTypes.isMimeTypeAudio
+import io.element.android.libraries.designsystem.components.BigIcon
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
+import io.element.android.libraries.designsystem.theme.components.ButtonSize
+import io.element.android.libraries.designsystem.theme.components.IconSource
+import io.element.android.libraries.designsystem.theme.components.OutlinedButton
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.mediaviewer.api.MediaInfo
import io.element.android.libraries.mediaviewer.api.helper.formatFileExtensionAndSize
+import io.element.android.libraries.mediaviewer.impl.R
import io.element.android.libraries.mediaviewer.impl.local.LocalMediaViewState
import io.element.android.libraries.mediaviewer.impl.local.rememberLocalMediaViewState
+import io.element.android.libraries.ui.strings.CommonStrings
+/**
+ * Ref: https://www.figma.com/design/pDlJZGBsri47FNTXMnEdXB/Compound-Android-Templates?node-id=3361-16623
+ */
@Composable
fun MediaFileView(
localMediaViewState: LocalMediaViewState,
uri: Uri?,
info: MediaInfo?,
- onClick: () -> Unit,
+ onOpenWith: (() -> Unit)?,
modifier: Modifier = Modifier,
) {
val isAudio = info?.mimeType.isMimeTypeAudio().orFalse()
localMediaViewState.isReady = uri != null
-
- val interactionSource = remember { MutableInteractionSource() }
Box(
modifier = modifier
- .padding(horizontal = 8.dp)
- .clickable(
- onClick = onClick,
- interactionSource = interactionSource,
- indication = null
- ),
+ .padding(horizontal = 8.dp),
contentAlignment = Alignment.Center
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
- Box(
- modifier = Modifier
- .size(72.dp)
- .clip(CircleShape)
- .background(ElementTheme.colors.iconPrimary),
- contentAlignment = Alignment.Center,
- ) {
- Icon(
- imageVector = if (isAudio) CompoundIcons.Audio() else CompoundIcons.Attachment(),
- contentDescription = null,
- tint = ElementTheme.colors.iconOnSolidPrimary,
- modifier = Modifier
- .size(32.dp)
- .rotate(if (isAudio) 0f else -45f),
- )
+ val isApk = info?.mimeType == MimeTypes.Apk
+ val icon = when {
+ isAudio -> CompoundIcons.Audio()
+ isApk -> CompoundIcons.Bug()
+ else -> CompoundIcons.Files()
}
+ BigIcon(
+ modifier = Modifier.align(Alignment.CenterHorizontally),
+ style = BigIcon.Style.Default(
+ vectorIcon = icon,
+ usePrimaryTint = true,
+ ),
+ )
if (info != null) {
Spacer(modifier = Modifier.height(20.dp))
Text(
@@ -100,6 +92,20 @@ fun MediaFileView(
overflow = TextOverflow.Ellipsis,
color = ElementTheme.colors.textPrimary
)
+ if (onOpenWith != null) {
+ val (icon, textResId) = if (isApk) {
+ IconSource.Resource(R.drawable.ic_apk_install) to CommonStrings.common_install_apk_android
+ } else {
+ IconSource.Vector(CompoundIcons.PopOut()) to CommonStrings.action_open_with
+ }
+ OutlinedButton(
+ modifier = Modifier.padding(top = 24.dp),
+ size = ButtonSize.Small,
+ leadingIcon = icon,
+ onClick = onOpenWith,
+ text = stringResource(id = textResId),
+ )
+ }
}
}
}
@@ -115,6 +121,6 @@ internal fun MediaFileViewPreview(
localMediaViewState = rememberLocalMediaViewState(),
uri = null,
info = info,
- onClick = {},
+ onOpenWith = {},
)
}
diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/file/MediaInfoFileProvider.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/file/MediaInfoFileProvider.kt
index 8e41a4809a..3214236253 100644
--- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/file/MediaInfoFileProvider.kt
+++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/file/MediaInfoFileProvider.kt
@@ -10,11 +10,15 @@ package io.element.android.libraries.mediaviewer.impl.local.file
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.libraries.mediaviewer.api.MediaInfo
-import io.element.android.libraries.mediaviewer.api.aPdfMediaInfo
+import io.element.android.libraries.mediaviewer.api.aZipMediaInfo
+import io.element.android.libraries.mediaviewer.api.anApkMediaInfo
+import io.element.android.libraries.mediaviewer.api.anAudioMediaInfo
open class MediaInfoFileProvider : PreviewParameterProvider {
override val values: Sequence
get() = sequenceOf(
- aPdfMediaInfo(),
+ aZipMediaInfo(),
+ anAudioMediaInfo(),
+ anApkMediaInfo(),
)
}
diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/player/MediaPlayerControllerView.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/player/MediaPlayerControllerView.kt
index b06b97f491..1f2b0b93d2 100644
--- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/player/MediaPlayerControllerView.kt
+++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/player/MediaPlayerControllerView.kt
@@ -28,6 +28,9 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.semantics.contentDescription
+import androidx.compose.ui.semantics.semantics
+import androidx.compose.ui.semantics.stateDescription
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
@@ -100,36 +103,54 @@ fun MediaPlayerControllerView(
contentColor = ElementTheme.colors.iconOnSolidPrimary,
)
}
+ val a11yPause = stringResource(CommonStrings.a11y_pause)
+ val a11yPlay = stringResource(CommonStrings.a11y_play)
IconButton(
modifier = Modifier
- .size(36.dp),
+ .size(36.dp)
+ .semantics {
+ stateDescription = if (state.isPlaying) a11yPause else a11yPlay
+ },
onClick = onTogglePlay,
colors = colors,
) {
if (state.isPlaying) {
Icon(
imageVector = CompoundIcons.PauseSolid(),
- contentDescription = stringResource(CommonStrings.a11y_pause)
+ contentDescription = null,
)
} else {
Icon(
imageVector = CompoundIcons.PlaySolid(),
- contentDescription = stringResource(CommonStrings.a11y_play)
+ contentDescription = null,
)
}
}
+ val position = state.displayProgressInMillis.toHumanReadableDuration()
+ val a11yPosition = stringResource(CommonStrings.a11y_position, position)
Text(
modifier = Modifier
.widthIn(min = 48.dp)
- .padding(horizontal = 8.dp),
- text = state.displayProgressInMillis.toHumanReadableDuration(),
+ .padding(horizontal = 8.dp)
+ .semantics {
+ contentDescription = a11yPosition
+ },
+ text = position,
textAlign = TextAlign.Center,
color = ElementTheme.colors.textPrimary,
style = ElementTheme.typography.fontBodyXsMedium,
)
var lastSelectedValue by remember { mutableFloatStateOf(-1f) }
Slider(
- modifier = Modifier.weight(1f),
+ modifier = Modifier
+ .weight(1f)
+ .semantics {
+ // Speak out a progress percent instead of milliseconds
+ stateDescription = buildString {
+ append((state.progressAsFloat * 100).toInt())
+ append("%")
+ }
+ },
valueRange = 0f..state.durationInMillis.toFloat(),
value = lastSelectedValue.takeIf { it >= 0 }
?: state.seekingToMillis?.toFloat()
@@ -146,30 +167,40 @@ fun MediaPlayerControllerView(
val formattedDuration = remember(state.durationInMillis) {
state.durationInMillis.toHumanReadableDuration()
}
+ val a11yDuration = stringResource(CommonStrings.a11y_duration, formattedDuration)
Text(
modifier = Modifier
.widthIn(min = 48.dp)
- .padding(horizontal = 8.dp),
+ .padding(horizontal = 8.dp)
+ .semantics {
+ contentDescription = a11yDuration
+ },
text = formattedDuration,
textAlign = TextAlign.Center,
color = ElementTheme.colors.textPrimary,
style = ElementTheme.typography.fontBodyXsMedium,
)
if (state.canMute) {
+ val a11yUnmute = stringResource(CommonStrings.common_unmute)
+ val a11yMute = stringResource(CommonStrings.common_mute)
IconButton(
onClick = onToggleMute,
+ modifier = Modifier
+ .semantics {
+ stateDescription = if (state.isMuted) a11yUnmute else a11yMute
+ },
) {
if (state.isMuted) {
Icon(
imageVector = CompoundIcons.VolumeOffSolid(),
tint = ElementTheme.colors.iconPrimary,
- contentDescription = stringResource(CommonStrings.common_unmute)
+ contentDescription = null,
)
} else {
Icon(
imageVector = CompoundIcons.VolumeOnSolid(),
tint = ElementTheme.colors.iconPrimary,
- contentDescription = stringResource(CommonStrings.common_mute)
+ contentDescription = null,
)
}
}
diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/video/MediaVideoView.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/video/MediaVideoView.kt
index 9d5e290857..d89762b72e 100644
--- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/video/MediaVideoView.kt
+++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/video/MediaVideoView.kt
@@ -57,6 +57,7 @@ import io.element.android.libraries.mediaviewer.impl.local.player.rememberExoPla
import io.element.android.libraries.mediaviewer.impl.local.player.seekToEnsurePlaying
import io.element.android.libraries.mediaviewer.impl.local.player.togglePlay
import io.element.android.libraries.mediaviewer.impl.local.rememberLocalMediaViewState
+import io.element.android.libraries.ui.utils.a11y.isTalkbackActive
import kotlinx.coroutines.delay
import me.saket.telephoto.zoomable.zoomable
import timber.log.Timber
@@ -162,12 +163,20 @@ private fun ExoPlayerMediaVideoView(
var autoHideController by remember { mutableIntStateOf(0) }
- LaunchedEffect(autoHideController) {
- delay(5.seconds)
- if (exoPlayer.isPlaying) {
+ val isTalkbackActive = isTalkbackActive()
+ LaunchedEffect(autoHideController, isTalkbackActive) {
+ if (isTalkbackActive) {
+ // Ensure that the controller is always visible when talkback is active
mediaPlayerControllerState = mediaPlayerControllerState.copy(
- isVisible = false,
+ isVisible = true,
)
+ } else {
+ delay(5.seconds)
+ if (exoPlayer.isPlaying) {
+ mediaPlayerControllerState = mediaPlayerControllerState.copy(
+ isVisible = false,
+ )
+ }
}
}
diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerDataSource.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerDataSource.kt
index 01ec68a336..6feb3c7d53 100644
--- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerDataSource.kt
+++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerDataSource.kt
@@ -11,12 +11,15 @@ package io.element.android.libraries.mediaviewer.impl.viewer
import androidx.annotation.VisibleForTesting
import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState
+import androidx.compose.runtime.ProduceStateScope
import androidx.compose.runtime.State
-import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
-import androidx.compose.runtime.remember
+import androidx.compose.runtime.produceState
+import androidx.compose.runtime.rememberUpdatedState
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.core.extensions.mapCatchingExceptions
+import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.media.MatrixMediaLoader
import io.element.android.libraries.matrix.api.media.MediaFile
import io.element.android.libraries.matrix.api.media.MediaSource
@@ -37,14 +40,17 @@ import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
-import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.SharingStarted
+import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.withContext
import timber.log.Timber
import java.util.concurrent.ConcurrentHashMap
class MediaViewerDataSource(
mode: MediaViewerMode,
+ coroutineScope: CoroutineScope,
private val dispatcher: CoroutineDispatcher,
private val galleryDataSource: MediaGalleryDataSource,
private val mediaLoader: MatrixMediaLoader,
@@ -76,40 +82,59 @@ class MediaViewerDataSource(
localMediaStates.clear()
}
+ /**
+ * Helper function to translate the [dataFlow] result to a Compose [State] that can be observed in the UI.
+ */
@Composable
- fun collectAsState(): State> {
- return remember { dataFlow() }.collectAsState(initialData())
+ fun produceState(
+ producer: suspend ProduceStateScope>.(StateFlow>) -> Unit
+ ): State> {
+ val latestProducer by rememberUpdatedState(producer)
+ return produceState(initialValue = initialData()) {
+ latestProducer(dataFlow)
+ }
+ }
+
+ /**
+ * Find the index of the page corresponding to the given eventId, or null if not found.
+ */
+ fun findEventIndex(eventId: EventId?): Int? {
+ if (eventId == null) return null
+ return dataFlow.value.indexOfFirst { (it as? MediaViewerPageData.MediaViewerData)?.eventId == eventId }.takeIf { it >= 0 }
}
@VisibleForTesting
- internal fun dataFlow(): Flow> {
- return galleryDataSource.groupedMediaItemsFlow()
- .map { groupedItems ->
- when (groupedItems) {
- AsyncData.Uninitialized,
- is AsyncData.Loading -> {
- persistentListOf(
- MediaViewerPageData.Loading(
- direction = Timeline.PaginationDirection.BACKWARDS,
- timestamp = systemClock.epochMillis(),
- pagerKey = Long.MIN_VALUE,
- )
+ internal val dataFlow: StateFlow> = galleryDataSource.groupedMediaItemsFlow()
+ .map { groupedItems ->
+ when (groupedItems) {
+ AsyncData.Uninitialized,
+ is AsyncData.Loading -> {
+ persistentListOf(
+ MediaViewerPageData.Loading(
+ direction = Timeline.PaginationDirection.BACKWARDS,
+ timestamp = systemClock.epochMillis(),
+ pagerKey = Long.MIN_VALUE,
)
- }
- is AsyncData.Failure -> {
- persistentListOf(
- MediaViewerPageData.Failure(groupedItems.error),
- )
- }
- is AsyncData.Success -> {
- withContext(dispatcher) {
- val mediaItems = groupedItems.data.getItems(galleryMode)
- buildMediaViewerPageList(mediaItems)
- }
+ )
+ }
+ is AsyncData.Failure -> {
+ persistentListOf(
+ MediaViewerPageData.Failure(groupedItems.error),
+ )
+ }
+ is AsyncData.Success -> {
+ withContext(dispatcher) {
+ val mediaItems = groupedItems.data.getItems(galleryMode)
+ buildMediaViewerPageList(mediaItems)
}
}
}
- }
+ }
+ .stateIn(
+ scope = CoroutineScope(coroutineScope.coroutineContext + dispatcher),
+ started = SharingStarted.Lazily,
+ initialValue = initialData(),
+ )
private fun initialData(): ImmutableList {
val initialMediaItems =
@@ -148,7 +173,7 @@ class MediaViewerDataSource(
is MediaItem.LoadingIndicator -> add(
MediaViewerPageData.Loading(
direction = mediaItem.direction,
- timestamp = systemClock.epochMillis(),
+ timestamp = mediaItem.timestamp,
pagerKey = pagerKeysHandler.getKey(mediaItem),
)
)
@@ -161,7 +186,9 @@ class MediaViewerDataSource(
}
suspend fun loadMore(direction: Timeline.PaginationDirection) {
- galleryDataSource.loadMore(direction)
+ if (galleryDataSource.isReady) {
+ galleryDataSource.loadMore(direction)
+ }
}
suspend fun loadMedia(data: MediaViewerPageData.MediaViewerData) {
diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerNode.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerNode.kt
index d834534e3e..e594daeb41 100644
--- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerNode.kt
+++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerNode.kt
@@ -13,6 +13,7 @@ import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
+import androidx.lifecycle.lifecycleScope
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin
@@ -118,6 +119,7 @@ class MediaViewerNode(
navigator = this,
dataSource = MediaViewerDataSource(
mode = inputs.mode,
+ coroutineScope = lifecycleScope,
dispatcher = coroutineDispatchers.computation,
galleryDataSource = mediaGallerySource,
mediaLoader = mediaLoader,
diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerPresenter.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerPresenter.kt
index d40026dd37..eec99468f7 100644
--- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerPresenter.kt
+++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerPresenter.kt
@@ -14,14 +14,12 @@ import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.IntState
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.State
-import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
-import androidx.compose.runtime.snapshotFlow
import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.AssistedFactory
import dev.zacsweers.metro.AssistedInject
@@ -45,10 +43,7 @@ import io.element.android.libraries.mediaviewer.impl.model.mediaPermissions
import io.element.android.libraries.ui.strings.CommonStrings
import kotlinx.collections.immutable.ImmutableList
import kotlinx.coroutines.CoroutineScope
-import kotlinx.coroutines.flow.distinctUntilChanged
-import kotlinx.coroutines.flow.filter
-import kotlinx.coroutines.flow.launchIn
-import kotlinx.coroutines.flow.onEach
+import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
import io.element.android.libraries.androidutils.R as UtilsR
@@ -75,12 +70,27 @@ class MediaViewerPresenter(
@Composable
override fun present(): MediaViewerState {
val coroutineScope = rememberCoroutineScope()
- val data = dataSource.collectAsState()
- val currentIndex = remember { mutableIntStateOf(searchIndex(data.value, inputs.eventId)) }
+ val currentIndex = remember { mutableIntStateOf(dataSource.findEventIndex(inputs.eventId) ?: 0) }
+ val data = dataSource.produceState { flow ->
+ flow.collectLatest { new ->
+ val existingItem = value.getOrNull(currentIndex.intValue)
+ val newItem = new.getOrNull(currentIndex.intValue)
+ if (existingItem is MediaViewerPageData.MediaViewerData && existingItem.eventId == inputs.eventId && newItem != existingItem) {
+ currentIndex.intValue = dataSource.findEventIndex(inputs.eventId) ?: 0
+ } else if (currentIndex.intValue > 0 && value.firstOrNull() is MediaViewerPageData.Loading &&
+ new.firstOrNull() !is MediaViewerPageData.Loading) {
+ // Restore index based on the eventId after the initial items have been loaded
+ currentIndex.intValue = dataSource.findEventIndex(inputs.eventId) ?: 0
+ }
+ value = new
+ }
+ }
+
val snackbarMessage by snackbarDispatcher.collectSnackbarMessageAsState()
- NoMoreItemsBackwardSnackBarDisplayer(currentIndex, data)
- NoMoreItemsForwardSnackBarDisplayer(currentIndex, data)
+ // Add both forward and backward pagination state checks to display a snackbar when there is no more items to load in either direction
+ NoMoreItemsSnackBarDisplayer(currentIndex, data, Timeline.PaginationDirection.FORWARDS)
+ NoMoreItemsSnackBarDisplayer(currentIndex, data, Timeline.PaginationDirection.BACKWARDS)
val permissions by room.permissionsAsState(MediaPermissions.DEFAULT) { perms ->
perms.mediaPermissions()
@@ -135,6 +145,7 @@ class MediaViewerPresenter(
}
is MediaViewerEvent.OpenInfo -> coroutineScope.launch {
mediaBottomSheetState = MediaBottomSheetState.Details(
+ fromGallery = false,
eventId = event.data.eventId,
canDelete = when (event.data.mediaInfo.senderId) {
null -> false
@@ -176,52 +187,39 @@ class MediaViewerPresenter(
}
@Composable
- private fun NoMoreItemsBackwardSnackBarDisplayer(
+ private fun NoMoreItemsSnackBarDisplayer(
currentIndex: IntState,
data: State>,
+ direction: Timeline.PaginationDirection,
) {
- // With newest-first ordering, backward loading indicator is at the last index
- val isRenderingLoadingBackward by remember {
- derivedStateOf {
- currentIndex.intValue == data.value.lastIndex &&
- data.value.size > 1 &&
- data.value.lastOrNull() is MediaViewerPageData.Loading
+ var previousIndex by remember { mutableIntStateOf(currentIndex.intValue) }
+ var previousDataSize by remember { mutableIntStateOf(data.value.size) }
+ var wasLoading: Boolean? by remember { mutableStateOf(null) }
+ LaunchedEffect(currentIndex.intValue, data.value) {
+ fun isLoading(index: Int, data: List, direction: Timeline.PaginationDirection): Boolean {
+ return when (direction) {
+ Timeline.PaginationDirection.BACKWARDS -> index == data.lastIndex && data.lastOrNull() is MediaViewerPageData.Loading
+ Timeline.PaginationDirection.FORWARDS -> index == 0 && data.firstOrNull() is MediaViewerPageData.Loading
+ }
}
- }
- if (isRenderingLoadingBackward) {
- LaunchedEffect(Unit) {
- // Observe the loading data vanishing
- snapshotFlow { data.value.lastOrNull() is MediaViewerPageData.Loading }
- .distinctUntilChanged()
- .filter { !it }
- .onEach { showNoMoreItemsSnackbar() }
- .launchIn(this)
+ // Reset the effect when the user navigate to another item so we only take into account index changes caused by data changes
+ if (previousIndex != currentIndex.intValue) {
+ wasLoading = null
+ previousIndex = currentIndex.intValue
+ }
+ // If we were navigating backwards and the data size grew, we can discard the previous value: it means we received new items
+ if (direction == Timeline.PaginationDirection.BACKWARDS && previousDataSize < data.value.size) {
+ wasLoading = null
}
- }
- }
- @Composable
- private fun NoMoreItemsForwardSnackBarDisplayer(
- currentIndex: IntState,
- data: State>,
- ) {
- // With newest-first ordering, forward loading indicator is at the first index
- val isRenderingLoadingForward by remember {
- derivedStateOf {
- currentIndex.intValue == 0 &&
- data.value.size > 1 &&
- data.value.firstOrNull() is MediaViewerPageData.Loading
- }
- }
- if (isRenderingLoadingForward) {
- LaunchedEffect(Unit) {
- // Observe the loading data vanishing
- snapshotFlow { data.value.firstOrNull() is MediaViewerPageData.Loading }
- .distinctUntilChanged()
- .filter { !it }
- .onEach { showNoMoreItemsSnackbar() }
- .launchIn(this)
+ val isLoading = isLoading(currentIndex.intValue, data.value, direction)
+
+ if (wasLoading == true && !isLoading) {
+ showNoMoreItemsSnackbar()
}
+
+ previousDataSize = data.value.size
+ wasLoading = isLoading
}
}
@@ -293,13 +291,4 @@ class MediaViewerPresenter(
CommonStrings.error_unknown
}
}
-
- private fun searchIndex(data: List, eventId: EventId?): Int {
- if (eventId == null) {
- return 0
- }
- return data.indexOfFirst {
- (it as? MediaViewerPageData.MediaViewerData)?.eventId == eventId
- }.coerceAtLeast(0)
- }
}
diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerState.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerState.kt
index 8a4da2aac3..a51d3c44e9 100644
--- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerState.kt
+++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerState.kt
@@ -8,6 +8,7 @@
package io.element.android.libraries.mediaviewer.impl.viewer
+import androidx.compose.runtime.Immutable
import androidx.compose.runtime.State
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage
@@ -29,6 +30,7 @@ data class MediaViewerState(
val eventSink: (MediaViewerEvent) -> Unit,
)
+@Immutable
sealed interface MediaViewerPageData {
val pagerKey: Long
diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerView.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerView.kt
index 5e9ced7d77..c4d442338a 100644
--- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerView.kt
+++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerView.kt
@@ -44,7 +44,6 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
-import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
@@ -56,6 +55,8 @@ import androidx.compose.ui.layout.onVisibilityChanged
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalInspectionMode
import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.semantics.clearAndSetSemantics
+import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.heading
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.style.TextOverflow
@@ -105,7 +106,7 @@ import me.saket.telephoto.zoomable.OverzoomEffect
import me.saket.telephoto.zoomable.ZoomSpec
import me.saket.telephoto.zoomable.rememberZoomableState
-val topAppBarHeight = 88.dp
+val topAppBarHeight = 112.dp
/**
* Ref: https://www.figma.com/design/pDlJZGBsri47FNTXMnEdXB/Compound-Android-Templates?node-id=3361-16623
@@ -179,9 +180,11 @@ fun MediaViewerView(
val pagerState = rememberPagerState(state.currentIndex, 0f) {
state.listData.size
}
- LaunchedEffect(pagerState) {
- snapshotFlow { pagerState.currentPage }.collect { page ->
- state.eventSink(MediaViewerEvent.OnNavigateTo(page))
+
+ LaunchedEffect(pagerState.targetPage, state.currentIndex) {
+ // Only emit an index navigation change when it's triggered by the user scrolling
+ if (pagerState.targetPage != state.currentIndex && pagerState.isScrollInProgress) {
+ state.eventSink(MediaViewerEvent.OnNavigateTo(pagerState.targetPage))
}
}
HorizontalPager(
@@ -236,6 +239,9 @@ fun MediaViewerView(
onRetry = {
state.eventSink(MediaViewerEvent.LoadMedia(dataForPage))
},
+ onOpenWith = {
+ state.eventSink(MediaViewerEvent.OpenWith(dataForPage))
+ },
onDismissError = {
state.eventSink(MediaViewerEvent.ClearLoadingError(dataForPage))
},
@@ -274,6 +280,7 @@ fun MediaViewerView(
state.eventSink(MediaViewerEvent.ViewInTimeline(it))
},
onShare = {
+ // Note: share action is not rendered when the bottom sheet is opened from the media viewer
(currentData as? MediaViewerPageData.MediaViewerData)?.let {
state.eventSink(MediaViewerEvent.Share(currentData))
}
@@ -282,6 +289,7 @@ fun MediaViewerView(
state.eventSink(MediaViewerEvent.Forward(it))
},
onDownload = {
+ // Note: download action is not rendered when the bottom sheet is opened from the media viewer
(currentData as? MediaViewerPageData.MediaViewerData)?.let {
state.eventSink(MediaViewerEvent.SaveOnDisk(currentData))
}
@@ -330,6 +338,7 @@ private fun MediaViewerPage(
isUserSelected: Boolean,
onDismiss: () -> Unit,
onRetry: () -> Unit,
+ onOpenWith: () -> Unit,
onDismissError: () -> Unit,
onShowOverlayChange: (Boolean) -> Unit,
audioFocus: AudioFocus?,
@@ -384,6 +393,7 @@ private fun MediaViewerPage(
currentOnShowOverlayChange(!currentShowOverlay)
}
},
+ onOpenWith = onOpenWith,
isUserSelected = isUserSelected,
audioFocus = audioFocus,
)
@@ -494,14 +504,20 @@ private fun MediaViewerTopBar(
TopAppBar(
title = {
if (senderName != null && dateSent != null) {
+ val description = stringResource(
+ CommonStrings.a11y_sent_by_sender_at_date,
+ senderName,
+ dateSent,
+ )
Column(
modifier = Modifier
.fillMaxWidth()
+ .clearAndSetSemantics {
+ heading()
+ contentDescription = description
+ },
) {
Text(
- modifier = Modifier.semantics {
- heading()
- },
text = senderName,
style = ElementTheme.typography.fontBodyMdMedium,
color = ElementTheme.colors.textPrimary,
diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/SingleMediaGalleryDataSource.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/SingleMediaGalleryDataSource.kt
index 7bbf397171..146ace8620 100644
--- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/SingleMediaGalleryDataSource.kt
+++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/SingleMediaGalleryDataSource.kt
@@ -30,6 +30,8 @@ class SingleMediaGalleryDataSource(
override fun groupedMediaItemsFlow() = flowOf(AsyncData.Success(data))
override fun getLastData(): AsyncData = AsyncData.Success(data)
+ override val isReady: Boolean = true
+
override suspend fun loadMore(direction: Timeline.PaginationDirection) = Unit
override suspend fun deleteItem(eventId: EventId) = Unit
diff --git a/libraries/mediaviewer/impl/src/main/res/values-ca/translations.xml b/libraries/mediaviewer/impl/src/main/res/values-ca/translations.xml
new file mode 100644
index 0000000000..44bf749c0f
--- /dev/null
+++ b/libraries/mediaviewer/impl/src/main/res/values-ca/translations.xml
@@ -0,0 +1,21 @@
+
+
+ "L\'arxiu s\'eliminarà de la sala i el membres no en tindran accés."
+ "Eliminar el document?"
+ "Comprova la connexió a internet i torna-ho a provar."
+ "Els documents, àudios i missatges de veu que s\'hagin penjat a la sala es mostraran aquí."
+ "Encara no s\'han pujat documents"
+ "Carregant documents…"
+ "Carregant multimèdia…"
+ "Documents"
+ "Multimèdia"
+ "Les imatges i vídeos penjats a la sala es mostraran aquí."
+ "Encara no s\'ha pujat multimèdia"
+ "Multimèdia i documents"
+ "Format del document"
+ "Nom del document"
+ "No hi ha més fitxers a mostrar"
+ "No hi ha més multimèdia a mostrar"
+ "Pujat per"
+ "Pujat el"
+
diff --git a/libraries/mediaviewer/impl/src/main/res/values-fi/translations.xml b/libraries/mediaviewer/impl/src/main/res/values-fi/translations.xml
index b3728cb9b7..05154b293c 100644
--- a/libraries/mediaviewer/impl/src/main/res/values-fi/translations.xml
+++ b/libraries/mediaviewer/impl/src/main/res/values-fi/translations.xml
@@ -16,6 +16,7 @@
"Tiedostonimi"
"Ei enää näytettäviä tiedostoja"
"Ei enää näytettävää mediaa"
+ "Tiedoston tiedot"
"Lähettäjä"
"Lähetetty"